提交
This commit is contained in:
1 parent
70bdd820ce
commit
bede140d12
988 files changed
+692310
-775910
No files matched your search
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 PL API 批量接入数据权限(PR4 Phase 3)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
IMPORT_LINE = "from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope\n"
|
||||
|
||||
|
||||
def ensure_import(content: str) -> str:
|
||||
if "from app.services.pl_data_scope import" in content:
|
||||
return content
|
||||
marker = "from app.core.db_auth import DbUserContext, get_db_current_user\n"
|
||||
if marker in content:
|
||||
return content.replace(marker, marker + IMPORT_LINE)
|
||||
return content
|
||||
|
||||
|
||||
def patch_get_helper(content: str, fn_name: str, model_name: str) -> str:
|
||||
"""_get_xxx(db, tenant_no, id) -> _get_xxx(db, tenant_no, id, user) + assert."""
|
||||
old_sig = f"def {fn_name}(db: Session, tenant_no: str, "
|
||||
if f"def {fn_name}(db: Session, tenant_no: str, " not in content:
|
||||
return content
|
||||
if f"def {fn_name}(db: Session, tenant_no: str, " in content and "user: DbUserContext" in content.split(f"def {fn_name}")[1].split("\n\n")[0]:
|
||||
return content
|
||||
|
||||
pattern = rf"(def {fn_name}\(\n db: Session,\n tenant_no: str,\n \w+_id: str,\n\) -> \w+:\n)( \w+ = \([\s\S]*?\n if not \w+:\n raise HTTPException\(status_code=404[^\n]+\n return \w+\n)"
|
||||
repl = rf"\1\2".replace("\\2", "") # noqa - use explicit replacement below
|
||||
|
||||
def replacer(m):
|
||||
body = m.group(0)
|
||||
if "assert_pl_row_in_scope" in body:
|
||||
return body
|
||||
var = re.search(r"if not (\w+):", body).group(1)
|
||||
return (
|
||||
body.replace(
|
||||
f"def {fn_name}(db: Session, tenant_no: str, ",
|
||||
f"def {fn_name}(db: Session, tenant_no: str, ",
|
||||
)
|
||||
.replace(
|
||||
f") -> ",
|
||||
f",\n user: DbUserContext,\n) -> ",
|
||||
1,
|
||||
)
|
||||
if ",\n user: DbUserContext," not in body
|
||||
else body
|
||||
)
|
||||
|
||||
# simpler manual replace per function
|
||||
old = f"""def {fn_name}(db: Session, tenant_no: str, """
|
||||
# find entity id param name
|
||||
m = re.search(rf"def {fn_name}\(db: Session, tenant_no: str, (\w+): str\)", content)
|
||||
if not m:
|
||||
return content
|
||||
id_param = m.group(1)
|
||||
old_block = f"""def {fn_name}(db: Session, tenant_no: str, {id_param}: str) ->"""
|
||||
if f"user: DbUserContext" in content[content.find(old_block) : content.find(old_block) + 200]:
|
||||
return content
|
||||
new_block = f"""def {fn_name}(db: Session, tenant_no: str, {id_param}: str, user: DbUserContext) ->"""
|
||||
content = content.replace(old_block, new_block, 1)
|
||||
# add assert before return
|
||||
needle = f"if not {id_param.split('_')[0] if False else ''}"
|
||||
# find variable name from query
|
||||
fn_body_start = content.find(new_block)
|
||||
fn_chunk = content[fn_body_start : fn_body_start + 600]
|
||||
var_m = re.search(r"(\w+) = \(\n db\.query", fn_chunk)
|
||||
if not var_m:
|
||||
var_m = re.search(r"(\w+) = \(\n db\.query", content[fn_body_start : fn_body_start + 800])
|
||||
var = var_m.group(1) if var_m else id_param.replace("_id", "").replace("carrier", "carrier")
|
||||
if var_m:
|
||||
var = var_m.group(1)
|
||||
else:
|
||||
# guess from id_param
|
||||
var = {"carrier_id": "carrier", "vehicle_id": "vehicle", "personnel_id": "personnel", "plan_id": "plan", "transport_id": "transport"}.get(id_param, "row")
|
||||
|
||||
insert = f""" assert_pl_row_in_scope(db, {var}, {model_name}, user, tenant_no)
|
||||
return {var}
|
||||
"""
|
||||
old_return = f" return {var}\n"
|
||||
if f"assert_pl_row_in_scope(db, {var}," not in content:
|
||||
content = content.replace(old_return, insert, 1)
|
||||
# fix _get calls: _get_xxx(db, tenant_no, id) -> _get_xxx(db, tenant_no, id, user)
|
||||
content = re.sub(
|
||||
rf"{fn_name}\(db, tenant_no, ([^,\)]+)\)",
|
||||
rf"{fn_name}(db, tenant_no, \1, user)",
|
||||
content,
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def add_scope_before_order(content: str, model: str, anchor: str) -> str:
|
||||
"""在 order_by 前插入 apply_pl_scope。"""
|
||||
if f"apply_pl_scope(query, {model}," in content:
|
||||
return content
|
||||
idx = content.find(anchor)
|
||||
if idx == -1:
|
||||
return content
|
||||
insert = f" query = apply_pl_scope(query, {model}, user, db, tenant_no)\n\n "
|
||||
# insert before anchor's line
|
||||
line_start = content.rfind("\n", 0, idx) + 1
|
||||
return content[:line_start] + insert + content[line_start:]
|
||||
|
||||
|
||||
def patch_base_data():
|
||||
path = ROOT / "app/api/pl/base_data.py"
|
||||
c = ensure_import(path.read_text())
|
||||
c = add_scope_before_order(c, "PlCarrier", " query = query.order_by(\n case((PlCarrier.order_num")
|
||||
c = add_scope_before_order(c, "PlVehicle", " vehicles = query.order_by(")
|
||||
if "vehicles = query.order_by(" not in c:
|
||||
c = add_scope_before_order(c, "PlVehicle", " query = query.order_by(")
|
||||
c = add_scope_before_order(c, "PlPersonnel", " query = query.order_by(\n case((PlPersonnel.order_num")
|
||||
# driver list - second PlPersonnel order_by
|
||||
if c.count("apply_pl_scope(query, PlPersonnel,") < 2:
|
||||
c = c.replace(
|
||||
" query = query.order_by(\n case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),\n PlPersonnel.create_time.desc(),\n )\n return [_personnel_to_driver_read(p) for p in query.all()]",
|
||||
" query = apply_pl_scope(query, PlPersonnel, user, db, tenant_no)\n\n query = query.order_by(\n case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),\n PlPersonnel.create_time.desc(),\n )\n return [_personnel_to_driver_read(p) for p in query.all()]",
|
||||
1,
|
||||
)
|
||||
for fn, model in (
|
||||
("_get_carrier", "PlCarrier"),
|
||||
("_get_vehicle", "PlVehicle"),
|
||||
("_get_personnel", "PlPersonnel"),
|
||||
):
|
||||
c = patch_get_helper(c, fn, model)
|
||||
path.write_text(c)
|
||||
print("patched base_data.py")
|
||||
|
||||
|
||||
def patch_plan():
|
||||
path = ROOT / "app/api/pl/plan.py"
|
||||
c = ensure_import(path.read_text())
|
||||
if "apply_pl_scope(query, PlPlan," not in c:
|
||||
c = c.replace(
|
||||
" total = query.count()\n offset = (page - 1) * page_size\n plans = query.order_by(PlPlan.create_time.desc())",
|
||||
" query = apply_pl_scope(query, PlPlan, user, db, tenant_no)\n\n total = query.count()\n offset = (page - 1) * page_size\n plans = query.order_by(PlPlan.create_time.desc())",
|
||||
1,
|
||||
)
|
||||
c = patch_get_helper(c, "_get_plan", "PlPlan")
|
||||
path.write_text(c)
|
||||
print("patched plan.py")
|
||||
|
||||
|
||||
def patch_transport():
|
||||
path = ROOT / "app/api/pl/transport.py"
|
||||
c = ensure_import(path.read_text())
|
||||
if "apply_pl_scope(query, PlTransport," not in c:
|
||||
c = c.replace(
|
||||
" total = query.count()\n offset = (page - 1) * page_size\n transports = query.order_by(PlTransport.create_time.desc())",
|
||||
" query = apply_pl_scope(query, PlTransport, user, db, tenant_no)\n\n total = query.count()\n offset = (page - 1) * page_size\n transports = query.order_by(PlTransport.create_time.desc())",
|
||||
1,
|
||||
)
|
||||
c = patch_get_helper(c, "_get_transport", "PlTransport")
|
||||
path.write_text(c)
|
||||
print("patched transport.py")
|
||||
|
||||
|
||||
def patch_inventory():
|
||||
path = ROOT / "app/api/pl/inventory.py"
|
||||
c = ensure_import(path.read_text())
|
||||
replacements = [
|
||||
(
|
||||
" total = query.count()\n offset = (page - 1) * page_size\n items = query.order_by(PlInventory.product_name)",
|
||||
" query = apply_pl_scope(query, PlInventory, user, db, tenant_no)\n\n total = query.count()\n offset = (page - 1) * page_size\n items = query.order_by(PlInventory.product_name)",
|
||||
),
|
||||
(
|
||||
" return query.order_by(PlInventoryWarning.create_time.desc()).all()",
|
||||
" query = apply_pl_scope(query, PlInventoryWarning, user, db, tenant_no)\n return query.order_by(PlInventoryWarning.create_time.desc()).all()",
|
||||
),
|
||||
(
|
||||
" total = query.count()\n offset = (page - 1) * page_size\n items = query.order_by(PlInventoryTransaction.create_time.desc())",
|
||||
" query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)\n\n total = query.count()\n offset = (page - 1) * page_size\n items = query.order_by(PlInventoryTransaction.create_time.desc())",
|
||||
),
|
||||
(
|
||||
" query = db.query(PlInventoryTransaction).filter(\n PlInventoryTransaction.tenant_no == tenant_no,\n PlInventoryTransaction.del_flag == 0,\n PlInventoryTransaction.create_time >= date_from,\n PlInventoryTransaction.create_time <= date_to,\n )\n\n if product_id:",
|
||||
" query = db.query(PlInventoryTransaction).filter(\n PlInventoryTransaction.tenant_no == tenant_no,\n PlInventoryTransaction.del_flag == 0,\n PlInventoryTransaction.create_time >= date_from,\n PlInventoryTransaction.create_time <= date_to,\n )\n query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)\n\n if product_id:",
|
||||
),
|
||||
(
|
||||
" return query.order_by(PlDocumentReminder.create_time.desc()).all()",
|
||||
" query = apply_pl_scope(query, PlDocumentReminder, user, db, tenant_no)\n return query.order_by(PlDocumentReminder.create_time.desc()).all()",
|
||||
),
|
||||
]
|
||||
for old, new in replacements:
|
||||
if old in c and new not in c:
|
||||
c = c.replace(old, new, 1)
|
||||
# warning update assert
|
||||
if "assert_pl_row_in_scope(db, warning," not in c:
|
||||
c = c.replace(
|
||||
" if not warning:\n raise HTTPException(status_code=404, detail=\"预警不存在\")\n\n if payload.status == \"resolved\":",
|
||||
" if not warning:\n raise HTTPException(status_code=404, detail=\"预警不存在\")\n assert_pl_row_in_scope(db, warning, PlInventoryWarning, user, tenant_no)\n\n if payload.status == \"resolved\":",
|
||||
1,
|
||||
)
|
||||
if "assert_pl_row_in_scope(db, reminder," not in c:
|
||||
c = c.replace(
|
||||
" if not reminder:\n raise HTTPException(status_code=404, detail=\"提醒不存在\")",
|
||||
" if not reminder:\n raise HTTPException(status_code=404, detail=\"提醒不存在\")\n assert_pl_row_in_scope(db, reminder, PlDocumentReminder, user, tenant_no)",
|
||||
1,
|
||||
)
|
||||
if "assert_pl_row_in_scope(db, inventory," not in c:
|
||||
c = c.replace(
|
||||
" if inventory:\n inventory.low_stock_warning = payload.low_stock_warning",
|
||||
" if inventory:\n assert_pl_row_in_scope(db, inventory, PlInventory, user, tenant_no)\n inventory.low_stock_warning = payload.low_stock_warning",
|
||||
1,
|
||||
)
|
||||
path.write_text(c)
|
||||
print("patched inventory.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
patch_base_data()
|
||||
patch_plan()
|
||||
patch_transport()
|
||||
patch_inventory()
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 site_access_token 为空的出入库单补 token。
|
||||
|
||||
Usage:
|
||||
cd backend && source venv/bin/activate
|
||||
python scripts/backfill_inventory_site_tokens.py
|
||||
python scripts/backfill_inventory_site_tokens.py --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.db_models import DbInboundOutbound
|
||||
from app.services.db_inventory_site_token import ensure_site_access_token
|
||||
|
||||
|
||||
def backfill(*, dry_run: bool = False) -> int:
|
||||
db: Session = SessionLocal()
|
||||
updated = 0
|
||||
try:
|
||||
rows = (
|
||||
db.query(DbInboundOutbound)
|
||||
.filter(
|
||||
DbInboundOutbound.del_flag == 0,
|
||||
(DbInboundOutbound.site_access_token.is_(None))
|
||||
| (DbInboundOutbound.site_access_token == ""),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
ensure_site_access_token(row)
|
||||
updated += 1
|
||||
if dry_run:
|
||||
db.rollback()
|
||||
print(f"[dry-run] would update {updated} inventory rows")
|
||||
else:
|
||||
db.commit()
|
||||
print(f"updated {updated} inventory rows")
|
||||
return updated
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Backfill db_inbound_outbound site_access_token")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without committing")
|
||||
args = parser.parse_args()
|
||||
backfill(dry_run=args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""校验运输计划模块 Pydantic Schema 是否具备 camelCase alias(防 422)。
|
||||
|
||||
用法(在 backend 目录):
|
||||
./venv/bin/python scripts/check_pl_json_aliases.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_FILES = [
|
||||
"app/schemas/pl/base_data.py",
|
||||
"app/schemas/pl/plan.py",
|
||||
"app/schemas/pl/transport.py",
|
||||
"app/schemas/pl/freight.py",
|
||||
"app/schemas/pl/inventory.py",
|
||||
]
|
||||
|
||||
# 单段 snake_case 公共字段可不加 alias(JSON 与 Python 同名)
|
||||
SINGLE_WORD_OK = {
|
||||
"id",
|
||||
"q",
|
||||
"page",
|
||||
"status",
|
||||
"remark",
|
||||
"model",
|
||||
"unit",
|
||||
"action",
|
||||
"comment",
|
||||
"total",
|
||||
"list",
|
||||
"products",
|
||||
"transports",
|
||||
"period",
|
||||
"qty",
|
||||
"amount",
|
||||
}
|
||||
|
||||
|
||||
def snake_to_camel(name: str) -> str:
|
||||
parts = name.split("_")
|
||||
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
||||
|
||||
|
||||
def iter_field_assignments(tree: ast.AST):
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
for stmt in node.body:
|
||||
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
||||
yield node.name, stmt.target.id, stmt.value
|
||||
elif isinstance(stmt, ast.Assign):
|
||||
for target in stmt.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
yield node.name, target.id, stmt.value
|
||||
|
||||
|
||||
def field_has_alias(value: ast.expr | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, ast.Call):
|
||||
for kw in value.keywords:
|
||||
if kw.arg == "alias":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
errors: list[str] = []
|
||||
|
||||
for rel in SCHEMA_FILES:
|
||||
path = root / rel
|
||||
if not path.exists():
|
||||
errors.append(f"缺少文件: {rel}")
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
for class_name, field_name, value in iter_field_assignments(tree):
|
||||
if "_" not in field_name:
|
||||
continue
|
||||
if field_name in SINGLE_WORD_OK or field_name == "model_config":
|
||||
continue
|
||||
if not field_has_alias(value):
|
||||
expected = snake_to_camel(field_name)
|
||||
errors.append(
|
||||
f"{rel}: {class_name}.{field_name} 缺少 Field(alias=\"{expected}\")"
|
||||
)
|
||||
|
||||
if errors:
|
||||
print("PL JSON alias 校验失败(可能导致前端 camelCase 请求 422):\n")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
print("\n规范: openspec/changes/transport-plan-management/specs/pl-api-json/spec.md")
|
||||
return 1
|
||||
|
||||
print("PL JSON alias 校验通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,283 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare sys_dict_type / sys_dict_data across SQL snapshots."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SX_SQL = ROOT / "docs/sql/sx.sql"
|
||||
PORTAL_SQL = ROOT / "docs/sql/shanxia2.0.sql"
|
||||
DIBANG_SQL = ROOT / "docs/sql/dibang.sql"
|
||||
|
||||
|
||||
def parse_insert_values(line: str) -> list[str] | None:
|
||||
m = re.search(r"VALUES\s*\((.*)\)\s*;?\s*$", line, re.I)
|
||||
if not m:
|
||||
return None
|
||||
inner = m.group(1)
|
||||
parts: list[str] = []
|
||||
cur: list[str] = []
|
||||
in_str = False
|
||||
esc = False
|
||||
for ch in inner:
|
||||
if esc:
|
||||
cur.append(ch)
|
||||
esc = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
esc = True
|
||||
cur.append(ch)
|
||||
continue
|
||||
if ch == "'":
|
||||
in_str = not in_str
|
||||
cur.append(ch)
|
||||
continue
|
||||
if ch == "," and not in_str:
|
||||
parts.append("".join(cur).strip())
|
||||
cur = []
|
||||
continue
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
parts.append("".join(cur).strip())
|
||||
return parts
|
||||
|
||||
|
||||
def unquote(v: str) -> str:
|
||||
v = v.strip()
|
||||
if v.upper() == "NULL":
|
||||
return ""
|
||||
if v.startswith("'") and v.endswith("'"):
|
||||
return v[1:-1].replace("''", "'").replace("\\'", "'")
|
||||
return v
|
||||
|
||||
|
||||
def load_sx_dicts(path: Path) -> tuple[dict[str, dict], dict[str, list[dict]]]:
|
||||
types: dict[str, dict] = {}
|
||||
data_by_type: dict[str, list[dict]] = defaultdict(list)
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
if "INSERT INTO `sys_dict_type`" in line:
|
||||
vals = parse_insert_values(line)
|
||||
if not vals or len(vals) < 4:
|
||||
continue
|
||||
# dict_id, tenant_id, dict_name, dict_type, ...
|
||||
dict_type = unquote(vals[3])
|
||||
types[dict_type] = {
|
||||
"dict_name": unquote(vals[2]),
|
||||
"tenant_id": unquote(vals[1]),
|
||||
"remark": unquote(vals[9]) if len(vals) > 9 else "",
|
||||
}
|
||||
elif "INSERT INTO `sys_dict_data`" in line:
|
||||
vals = parse_insert_values(line)
|
||||
if not vals or len(vals) < 6:
|
||||
continue
|
||||
# dict_code, tenant_id, dict_sort, dict_label, dict_value, dict_type, ...
|
||||
dict_type = unquote(vals[5])
|
||||
data_by_type[dict_type].append(
|
||||
{
|
||||
"label": unquote(vals[3]),
|
||||
"value": unquote(vals[4]),
|
||||
"sort": unquote(vals[2]),
|
||||
"css_class": unquote(vals[6]) if len(vals) > 6 else "",
|
||||
"list_class": unquote(vals[7]) if len(vals) > 7 else "",
|
||||
}
|
||||
)
|
||||
return types, data_by_type
|
||||
|
||||
|
||||
def load_portal_dicts(path: Path) -> tuple[dict[str, dict], dict[str, list[dict]]]:
|
||||
types: dict[str, dict] = {}
|
||||
data_by_type: dict[str, list[dict]] = defaultdict(list)
|
||||
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
if "INSERT INTO `sys_dict_type`" in line:
|
||||
vals = parse_insert_values(line)
|
||||
if not vals or len(vals) < 3:
|
||||
continue
|
||||
# id, dict_name, dict_type, tenant_no, ...
|
||||
dict_type = unquote(vals[2])
|
||||
types[dict_type] = {
|
||||
"dict_name": unquote(vals[1]),
|
||||
"tenant_no": unquote(vals[3]),
|
||||
"remark": unquote(vals[5]) if len(vals) > 5 else "",
|
||||
}
|
||||
elif "INSERT INTO `sys_dict_data`" in line:
|
||||
vals = parse_insert_values(line)
|
||||
if not vals or len(vals) < 5:
|
||||
continue
|
||||
# id, dict_label, dict_value, tag_type, dict_type, tenant_no, ...
|
||||
dict_type = unquote(vals[4])
|
||||
data_by_type[dict_type].append(
|
||||
{
|
||||
"label": unquote(vals[1]),
|
||||
"value": unquote(vals[2]),
|
||||
"tag_type": unquote(vals[3]),
|
||||
"sort": unquote(vals[6]) if len(vals) > 6 else "",
|
||||
}
|
||||
)
|
||||
return types, data_by_type
|
||||
|
||||
|
||||
def item_key(item: dict) -> str:
|
||||
return f"{item['value']}|{item['label']}"
|
||||
|
||||
|
||||
def compare_data(sx_items: list[dict], portal_items: list[dict]) -> dict:
|
||||
sx_map = {item_key(i): i for i in sx_items}
|
||||
portal_map = {item_key(i): i for i in portal_items}
|
||||
sx_keys = set(sx_map)
|
||||
portal_keys = set(portal_map)
|
||||
return {
|
||||
"only_sx": sorted(sx_keys - portal_keys),
|
||||
"only_portal": sorted(portal_keys - sx_keys),
|
||||
"common": sorted(sx_keys & portal_keys),
|
||||
"sx_count": len(sx_items),
|
||||
"portal_count": len(portal_items),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sx_types, sx_data = load_sx_dicts(SX_SQL)
|
||||
portal_types, portal_data = load_portal_dicts(PORTAL_SQL)
|
||||
|
||||
sx_type_keys = set(sx_types)
|
||||
portal_type_keys = set(portal_types)
|
||||
|
||||
only_sx_types = sorted(sx_type_keys - portal_type_keys)
|
||||
only_portal_types = sorted(portal_type_keys - sx_type_keys)
|
||||
common_types = sorted(sx_type_keys & portal_type_keys)
|
||||
|
||||
def count_by_prefix(types: set[str], prefix: str) -> int:
|
||||
return sum(1 for t in types if t.startswith(prefix))
|
||||
|
||||
sx_data_total = sum(len(v) for v in sx_data.values())
|
||||
portal_data_total = sum(len(v) for v in portal_data.values())
|
||||
portal_active = sum(
|
||||
1 for items in portal_data.values() for i in items
|
||||
) # placeholder
|
||||
|
||||
print("=" * 72)
|
||||
print("字典对比总览(基于 docs/sql 快照)")
|
||||
print("=" * 72)
|
||||
print(f"{'':28} {'sx.sql':>12} {'shanxia2.0':>12}")
|
||||
print(f"{'字典类型数':28} {len(sx_type_keys):>12} {len(portal_type_keys):>12}")
|
||||
print(f"{'字典项数':28} {sx_data_total:>12} {portal_data_total:>12}")
|
||||
print(f"{'crm_* 类型数':28} {count_by_prefix(sx_type_keys,'crm_'):>12} {count_by_prefix(portal_type_keys,'crm_'):>12}")
|
||||
print(f"{'xg_* 类型数(学工)':28} {count_by_prefix(sx_type_keys,'xg_'):>12} {count_by_prefix(portal_type_keys,'xg_'):>12}")
|
||||
print(f"{'sys_* 类型数':28} {count_by_prefix(sx_type_keys,'sys_'):>12} {count_by_prefix(portal_type_keys,'sys_'):>12}")
|
||||
print(f"{'pl_* 类型数(运输)':28} {count_by_prefix(sx_type_keys,'pl_'):>12} {count_by_prefix(portal_type_keys,'pl_'):>12}")
|
||||
print()
|
||||
|
||||
# semantic alias pairs
|
||||
alias_pairs = [
|
||||
("sys_user_sex", "sys_sex"),
|
||||
("sys_show_hide", "sys_visible"),
|
||||
("sys_normal_disable", "sys_status"),
|
||||
]
|
||||
print("--- 语义相近但类型码不同的「别名」---")
|
||||
for sx_t, portal_t in alias_pairs:
|
||||
in_sx = sx_t in sx_types or portal_t in sx_types
|
||||
in_portal = sx_t in portal_types or portal_t in portal_types
|
||||
if in_sx or in_portal:
|
||||
print(
|
||||
f" sx: {sx_t} ({len(sx_data.get(sx_t,[]))}项) "
|
||||
f"| portal: {portal_t} ({len(portal_data.get(portal_t,[]))}项) "
|
||||
f"+ portal {sx_t} ({len(portal_data.get(sx_t,[]))}项)"
|
||||
)
|
||||
print()
|
||||
|
||||
crm_prefixes = ("crm_", "xs_", "bx_")
|
||||
crm_only_sx = [t for t in only_sx_types if t.startswith(crm_prefixes) or "crm" in t or t.startswith("xs_")]
|
||||
other_only_sx = [t for t in only_sx_types if t not in crm_only_sx]
|
||||
|
||||
print("=" * 72)
|
||||
print("字典类型 (sys_dict_type) 差异明细")
|
||||
print("=" * 72)
|
||||
print(f"类型码相同(两边都有): {len(common_types)}")
|
||||
print(f"仅 sx 有(门户缺失): {len(only_sx_types)}")
|
||||
print(f"仅门户有(sx 无,多为 2.0 新增): {len(only_portal_types)}")
|
||||
print()
|
||||
|
||||
print("--- 仅门户有的字典类型(shanxia2.0 新增能力)---")
|
||||
for t in only_portal_types:
|
||||
print(f" {t:40} {portal_types[t]['dict_name']}")
|
||||
print()
|
||||
|
||||
print("--- 仅 sx 有 · CRM/业务相关(建议评估是否迁入)---")
|
||||
for t in crm_only_sx:
|
||||
print(f" {t:40} {sx_types[t]['dict_name']} (data:{len(sx_data.get(t, []))})")
|
||||
print()
|
||||
|
||||
print("--- 仅 sx 有 · 学工/校园 xg_*(门户一般不需要)---")
|
||||
xg_only = [t for t in only_sx_types if t.startswith("xg_")]
|
||||
for t in xg_only[:20]:
|
||||
print(f" {t:40} {sx_types[t]['dict_name']} (data:{len(sx_data.get(t, []))})")
|
||||
if len(xg_only) > 20:
|
||||
print(f" ... 共 {len(xg_only)} 个 xg_* 类型")
|
||||
print()
|
||||
|
||||
print("--- 仅 sx 有 · 若依框架 sys_*(可能与门户同名不同义)---")
|
||||
sys_overlap_missing = [t for t in only_sx_types if t.startswith("sys_")]
|
||||
for t in sys_overlap_missing:
|
||||
print(f" {t:40} {sx_types[t]['dict_name']} (data:{len(sx_data.get(t, []))})")
|
||||
print()
|
||||
|
||||
print("--- 仅 sx 有 · 其他 ---")
|
||||
for t in other_only_sx:
|
||||
if not t.startswith("xg_"):
|
||||
print(f" {t:40} {sx_types[t]['dict_name']} (data:{len(sx_data.get(t, []))})")
|
||||
print()
|
||||
|
||||
print("=" * 72)
|
||||
print("同名类型下字典项 (sys_dict_data) 差异(sx ∩ 门户)")
|
||||
print("=" * 72)
|
||||
diff_types = []
|
||||
for t in common_types:
|
||||
cmp = compare_data(sx_data.get(t, []), portal_data.get(t, []))
|
||||
if cmp["only_sx"] or cmp["only_portal"] or cmp["sx_count"] != cmp["portal_count"]:
|
||||
diff_types.append((t, cmp))
|
||||
print(f"类型码相同但字典项不完全一致: {len(diff_types)} / {len(common_types)}")
|
||||
print()
|
||||
for t, cmp in sorted(diff_types, key=lambda x: x[0]):
|
||||
sx_name = sx_types[t]["dict_name"]
|
||||
portal_name = portal_types[t]["dict_name"]
|
||||
print(f"[{t}] sx:{sx_name} | portal:{portal_name}")
|
||||
print(f" sx项数={cmp['sx_count']} portal项数={cmp['portal_count']}")
|
||||
if cmp["only_sx"]:
|
||||
print(f" 仅sx有 ({len(cmp['only_sx'])}):")
|
||||
for k in cmp["only_sx"][:8]:
|
||||
print(f" - {k}")
|
||||
if len(cmp["only_sx"]) > 8:
|
||||
print(f" ... +{len(cmp['only_sx']) - 8}")
|
||||
if cmp["only_portal"]:
|
||||
print(f" 仅门户有 ({len(cmp['only_portal'])}):")
|
||||
for k in cmp["only_portal"][:8]:
|
||||
print(f" - {k}")
|
||||
if len(cmp["only_portal"]) > 8:
|
||||
print(f" ... +{len(cmp['only_portal']) - 8}")
|
||||
print()
|
||||
|
||||
print("=" * 72)
|
||||
print("CRM 业务字典(sx 有、门户无)明细")
|
||||
print("=" * 72)
|
||||
crm_types = sorted(
|
||||
t
|
||||
for t in only_sx_types
|
||||
if t.startswith("crm_") or t.startswith("xs_") or t in ("contract_type", "contract_nature")
|
||||
)
|
||||
for t in crm_types:
|
||||
print(f"\n## {t} — {sx_types[t]['dict_name']}")
|
||||
for item in sorted(sx_data.get(t, []), key=lambda x: (x.get("sort") or "", x["value"])):
|
||||
print(f" {item['value']:20} {item['label']}")
|
||||
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("地磅系统 dibang.sql")
|
||||
print("=" * 72)
|
||||
dibang_text = DIBANG_SQL.read_text(encoding="utf-8", errors="ignore")
|
||||
has_dict = "sys_dict" in dibang_text
|
||||
print("含 sys_dict 表/数据:", "是" if has_dict else "否(地磅无独立字典表,枚举多在业务字段或前端硬编码)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,356 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge zkxw-dict.sql + sx-dict.sql into shanxia2.0 sys_dict_* INSERT script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
ZKXW_SQL = ROOT / "docs/sql/zkxw-dict.sql"
|
||||
SX_SQL = ROOT / "docs/sql/sx-dict.sql"
|
||||
SHANXIA_SQL = ROOT / "docs/sql/shanxia2.0.sql"
|
||||
OUT_SQL = ROOT / "docs/sql/reimport-dict-from-zkxw-sx.sql"
|
||||
TENANT = "shanxia2.0"
|
||||
# sx 源库部分 CRM 字典无时间戳;目标表 create_time/update_time 为 NOT NULL
|
||||
DEFAULT_DT = "2026-05-30 00:00:00"
|
||||
|
||||
# shanxia2.0 门户内置字典(不在 zkxw-dict / sx-dict 中,从基线 SQL 保留)
|
||||
PORTAL_BASELINE_TYPES = frozenset(
|
||||
{
|
||||
"sys_status",
|
||||
"sys_sex",
|
||||
"sys_data_scope",
|
||||
"sys_visible",
|
||||
"menu_type",
|
||||
"leave_status",
|
||||
"wf_process_status",
|
||||
"wf_node_type",
|
||||
"wf_approve_type",
|
||||
"user_role",
|
||||
}
|
||||
)
|
||||
|
||||
DICT_TYPE_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
|
||||
TAG_TYPE_MAP = {
|
||||
"default": "",
|
||||
"primary": "primary",
|
||||
"success": "success",
|
||||
"danger": "danger",
|
||||
"warning": "warning",
|
||||
"info": "info",
|
||||
}
|
||||
|
||||
|
||||
def split_sql_values(values_part: str) -> list:
|
||||
parts: list[str] = []
|
||||
cur: list[str] = []
|
||||
in_str = False
|
||||
i = 0
|
||||
while i < len(values_part):
|
||||
c = values_part[i]
|
||||
if in_str:
|
||||
if c == "\\" and i + 1 < len(values_part):
|
||||
cur.append(c)
|
||||
cur.append(values_part[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if c == "'":
|
||||
if i + 1 < len(values_part) and values_part[i + 1] == "'":
|
||||
cur.append("''")
|
||||
i += 2
|
||||
continue
|
||||
in_str = False
|
||||
cur.append(c)
|
||||
else:
|
||||
cur.append(c)
|
||||
else:
|
||||
if c == "'":
|
||||
in_str = True
|
||||
cur.append(c)
|
||||
elif c == ",":
|
||||
parts.append("".join(cur).strip())
|
||||
cur = []
|
||||
else:
|
||||
cur.append(c)
|
||||
i += 1
|
||||
if cur:
|
||||
parts.append("".join(cur).strip())
|
||||
return parts
|
||||
|
||||
|
||||
def unquote(val: str):
|
||||
val = val.strip()
|
||||
if val.upper() == "NULL":
|
||||
return None
|
||||
if val.startswith("'") and val.endswith("'"):
|
||||
return val[1:-1].replace("''", "'")
|
||||
if re.fullmatch(r"-?\d+", val):
|
||||
return int(val)
|
||||
return val
|
||||
|
||||
|
||||
def parse_insert(line: str) -> tuple[str, list[str], list] | None:
|
||||
m = re.match(
|
||||
r"INSERT INTO `(\w+)` \(([^)]+)\) VALUES \((.+)\);\s*$",
|
||||
line.strip(),
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
table = m.group(1)
|
||||
cols = [c.strip().strip("`") for c in m.group(2).split(",")]
|
||||
vals = [unquote(v) for v in split_sql_values(m.group(3))]
|
||||
return table, cols, vals
|
||||
|
||||
|
||||
def sql_literal(val) -> str:
|
||||
if val is None:
|
||||
return "NULL"
|
||||
if isinstance(val, int):
|
||||
return str(val)
|
||||
s = str(val).replace("\\", "\\\\").replace("'", "''")
|
||||
return f"'{s}'"
|
||||
|
||||
|
||||
def is_valid_dict_type(code: str | None) -> bool:
|
||||
return bool(code) and bool(DICT_TYPE_RE.match(code))
|
||||
|
||||
|
||||
def map_tag_type(list_class: str | None) -> str:
|
||||
if not list_class:
|
||||
return ""
|
||||
key = str(list_class).strip().lower()
|
||||
return TAG_TYPE_MAP.get(key, key if key in TAG_TYPE_MAP.values() else "")
|
||||
|
||||
|
||||
def sx_type_id(dict_id: int) -> str:
|
||||
return f"SXDT{dict_id:016X}"
|
||||
|
||||
|
||||
def sx_data_id(dict_code: int) -> str:
|
||||
return f"SXDC{dict_code:016X}"
|
||||
|
||||
|
||||
def normalize_row(row: dict) -> dict:
|
||||
out = dict(row)
|
||||
ct = out.get("create_time")
|
||||
ut = out.get("update_time")
|
||||
if not ct:
|
||||
out["create_time"] = ut or DEFAULT_DT
|
||||
if not out.get("update_time"):
|
||||
out["update_time"] = out["create_time"] or DEFAULT_DT
|
||||
return out
|
||||
|
||||
|
||||
def row_to_insert(table: str, cols: list[str], row: dict) -> str:
|
||||
row = normalize_row(row)
|
||||
ordered = [row[c] for c in cols]
|
||||
vals = ", ".join(sql_literal(v) for v in ordered)
|
||||
col_sql = ", ".join(f"`{c}`" for c in cols)
|
||||
return f"INSERT INTO `{table}` ({col_sql}) VALUES ({vals});"
|
||||
|
||||
|
||||
def load_portal_baseline():
|
||||
types: dict[str, dict] = {}
|
||||
data: list[dict] = []
|
||||
for line in SHANXIA_SQL.read_text(encoding="utf-8").splitlines():
|
||||
parsed = parse_insert(line)
|
||||
if not parsed:
|
||||
continue
|
||||
table, cols, vals = parsed
|
||||
row = dict(zip(cols, vals))
|
||||
if table == "sys_dict_type":
|
||||
code = row.get("dict_type")
|
||||
if code not in PORTAL_BASELINE_TYPES or row.get("del_flag", 0) != 0:
|
||||
continue
|
||||
types[code] = row
|
||||
elif table == "sys_dict_data":
|
||||
code = row.get("dict_type")
|
||||
if code not in PORTAL_BASELINE_TYPES or row.get("del_flag", 0) != 0:
|
||||
continue
|
||||
row.setdefault("tag_type", "")
|
||||
data.append(row)
|
||||
return types, data
|
||||
|
||||
|
||||
def load_zkxw():
|
||||
types: dict[str, dict] = {}
|
||||
data: list[dict] = []
|
||||
for line in ZKXW_SQL.read_text(encoding="utf-8").splitlines():
|
||||
parsed = parse_insert(line)
|
||||
if not parsed:
|
||||
continue
|
||||
table, cols, vals = parsed
|
||||
row = dict(zip(cols, vals))
|
||||
if row.get("del_flag", 0) != 0:
|
||||
continue
|
||||
row["tenant_no"] = TENANT
|
||||
if table == "sys_dict_type":
|
||||
code = row.get("dict_type")
|
||||
if is_valid_dict_type(code):
|
||||
types[code] = row
|
||||
elif table == "sys_dict_data":
|
||||
code = row.get("dict_type")
|
||||
if not is_valid_dict_type(code):
|
||||
continue
|
||||
row["tenant_no"] = TENANT
|
||||
row.setdefault("tag_type", "")
|
||||
data.append(row)
|
||||
return types, data
|
||||
|
||||
|
||||
def load_sx():
|
||||
types: dict[str, dict] = {}
|
||||
data: list[dict] = []
|
||||
for line in SX_SQL.read_text(encoding="utf-8").splitlines():
|
||||
parsed = parse_insert(line)
|
||||
if not parsed:
|
||||
continue
|
||||
table, cols, vals = parsed
|
||||
row = dict(zip(cols, vals))
|
||||
if table == "sys_dict_type":
|
||||
dict_id = row.get("dict_id")
|
||||
code = row.get("dict_type")
|
||||
name = row.get("dict_name")
|
||||
if not is_valid_dict_type(code):
|
||||
continue
|
||||
if str(name) in ("000000", TENANT, "zkxw", "0"):
|
||||
continue
|
||||
types[code] = {
|
||||
"id": sx_type_id(int(dict_id)),
|
||||
"dict_name": name,
|
||||
"dict_type": code,
|
||||
"tenant_no": TENANT,
|
||||
"order_num": 0,
|
||||
"remark": row.get("remark") or "",
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time"),
|
||||
"update_by": None,
|
||||
"update_time": row.get("update_time") or row.get("create_time"),
|
||||
"del_flag": 0,
|
||||
}
|
||||
elif table == "sys_dict_data":
|
||||
dict_code = row.get("dict_code")
|
||||
code = row.get("dict_type")
|
||||
label = row.get("dict_label")
|
||||
value = row.get("dict_value")
|
||||
if dict_code is None or not is_valid_dict_type(code):
|
||||
continue
|
||||
if not label or not str(label).strip():
|
||||
continue
|
||||
if str(label) in ("planned", "zsjy_visit_status") and str(value) in (
|
||||
"zsjy_visit_status",
|
||||
"planned",
|
||||
):
|
||||
continue
|
||||
create_by = row.get("create_by")
|
||||
data.append(
|
||||
{
|
||||
"id": sx_data_id(int(dict_code)),
|
||||
"dict_label": label,
|
||||
"dict_value": value,
|
||||
"tag_type": map_tag_type(row.get("list_class")),
|
||||
"dict_type": code,
|
||||
"tenant_no": TENANT,
|
||||
"order_num": row.get("dict_sort") or 0,
|
||||
"remark": row.get("remark") or "",
|
||||
"create_by": str(create_by) if create_by is not None else None,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time"),
|
||||
"update_by": None,
|
||||
"update_time": row.get("update_time") or row.get("create_time"),
|
||||
"del_flag": 0,
|
||||
}
|
||||
)
|
||||
return types, data
|
||||
|
||||
|
||||
def main():
|
||||
portal_types, portal_data = load_portal_baseline()
|
||||
zkxw_types, zkxw_data = load_zkxw()
|
||||
sx_types, sx_data = load_sx()
|
||||
|
||||
merged_types = dict(portal_types)
|
||||
merged_types.update(zkxw_types)
|
||||
merged_types.update(sx_types)
|
||||
|
||||
type_cols = [
|
||||
"id",
|
||||
"dict_name",
|
||||
"dict_type",
|
||||
"tenant_no",
|
||||
"order_num",
|
||||
"remark",
|
||||
"create_by",
|
||||
"create_dept_id",
|
||||
"create_time",
|
||||
"update_by",
|
||||
"update_time",
|
||||
"del_flag",
|
||||
]
|
||||
data_cols = [
|
||||
"id",
|
||||
"dict_label",
|
||||
"dict_value",
|
||||
"tag_type",
|
||||
"dict_type",
|
||||
"tenant_no",
|
||||
"order_num",
|
||||
"remark",
|
||||
"create_by",
|
||||
"create_dept_id",
|
||||
"create_time",
|
||||
"update_by",
|
||||
"update_time",
|
||||
"del_flag",
|
||||
]
|
||||
|
||||
# portal baseline -> zkxw -> sx
|
||||
seen_data_ids: set[str] = set()
|
||||
merged_data: list[dict] = []
|
||||
for row in portal_data + zkxw_data + sx_data:
|
||||
rid = row["id"]
|
||||
if rid in seen_data_ids:
|
||||
continue
|
||||
seen_data_ids.add(rid)
|
||||
if row["dict_type"] not in merged_types:
|
||||
continue
|
||||
merged_data.append(row)
|
||||
|
||||
lines = [
|
||||
"-- Reimport dictionary data for shanxia2.0",
|
||||
"-- Sources: shanxia2.0 portal baseline + zkxw-dict.sql + sx-dict.sql",
|
||||
"-- Generated by: backend/scripts/generate_dict_reimport_sql.py",
|
||||
"",
|
||||
"SET NAMES utf8mb4;",
|
||||
"SET FOREIGN_KEY_CHECKS = 0;",
|
||||
"",
|
||||
"DELETE FROM `sys_dict_data`;",
|
||||
"DELETE FROM `sys_dict_type`;",
|
||||
"",
|
||||
"BEGIN;",
|
||||
"",
|
||||
"-- sys_dict_type",
|
||||
]
|
||||
for code in sorted(merged_types.keys()):
|
||||
lines.append(row_to_insert("sys_dict_type", type_cols, merged_types[code]))
|
||||
|
||||
lines.append("")
|
||||
lines.append("-- sys_dict_data")
|
||||
for row in merged_data:
|
||||
lines.append(row_to_insert("sys_dict_data", data_cols, row))
|
||||
|
||||
lines.extend(["", "COMMIT;", "", "SET FOREIGN_KEY_CHECKS = 1;", ""])
|
||||
|
||||
OUT_SQL.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(
|
||||
f"Wrote {OUT_SQL.relative_to(ROOT)}: "
|
||||
f"{len(merged_types)} types, {len(merged_data)} data rows "
|
||||
f"(portal={len(portal_types)}, zkxw={len(zkxw_types)}, sx={len(sx_types)})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,827 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库报销(bx_*)数据迁入 shanxia2.0。
|
||||
|
||||
阶段:
|
||||
base — 费用项目 + 工作流定义/节点(替换门户预置工作流)
|
||||
advance — 备用金申请 + 审批
|
||||
reimburse — 报销单 + 明细 + 审批 + 冲抵
|
||||
attachment— 附件(依赖业务单 biz_id)
|
||||
all — 按依赖顺序全部执行
|
||||
|
||||
用户外键:docs/data-migration/mappings/user_mapping_result.csv(bx_user_id);
|
||||
bx_user_id=1(原系统管理员)→ 门户 admin。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_bx_data.py --dry-run --phase base --env-file .env.production
|
||||
venv/bin/python scripts/migrate_bx_data.py --phase all --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
BX_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
PHASE_STEPS: Dict[str, Tuple[str, ...]] = {
|
||||
"base": ("bx_expense_item", "bx_workflow_def", "bx_workflow_node"),
|
||||
"advance": ("bx_advance_apply", "bx_advance_approval"),
|
||||
"reimburse": (
|
||||
"bx_reimburse_apply",
|
||||
"bx_reimburse_detail",
|
||||
"bx_reimburse_approval",
|
||||
"bx_reimburse_offset",
|
||||
),
|
||||
"attachment": ("bx_attachment",),
|
||||
}
|
||||
|
||||
ALL_STEPS = PHASE_STEPS["base"] + PHASE_STEPS["advance"] + PHASE_STEPS["reimburse"] + PHASE_STEPS["attachment"]
|
||||
|
||||
APPLY_CODE_RE = re.compile(r"^BX(\d{8})(\d{3})$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
steps: Dict[str, StepStats] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str) -> StepStats:
|
||||
if name not in self.steps:
|
||||
self.steps[name] = StepStats()
|
||||
return self.steps[name]
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_bx_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
bx_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
bx_raw = (row.get("bx_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if bx_raw and new_id:
|
||||
try:
|
||||
bx_map[int(bx_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return bx_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def map_bx_user_id(
|
||||
raw: Any,
|
||||
bx_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> Optional[str]:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
text_val = str(raw).strip()
|
||||
if len(text_val) >= 32 and not text_val.isdigit():
|
||||
return text_val
|
||||
try:
|
||||
uid = int(text_val)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户引用 {raw!r}")
|
||||
return admin_id if required else None
|
||||
if uid == BX_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = bx_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: bx_user_id={uid} 无映射")
|
||||
return admin_id if required else None
|
||||
return mapped
|
||||
|
||||
|
||||
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s[:max_len] if max_len else s
|
||||
|
||||
|
||||
def _dec(value: Any) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def parse_reimburse_meta(apply_code: str) -> Tuple[date, int]:
|
||||
m = APPLY_CODE_RE.match(apply_code or "")
|
||||
if not m:
|
||||
raise ValueError(f"无法解析报销单号: {apply_code!r}")
|
||||
ymd, seq = m.group(1), int(m.group(2))
|
||||
d = date(int(ymd[0:4]), int(ymd[4:6]), int(ymd[6:8]))
|
||||
return d, seq
|
||||
|
||||
|
||||
def fetch_source_rows(
|
||||
engine: Engine, table: str, *, include_deleted: bool, no_del_flag: bool = False
|
||||
) -> List[dict]:
|
||||
if no_del_flag:
|
||||
where = ""
|
||||
elif include_deleted:
|
||||
where = ""
|
||||
else:
|
||||
where = "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def target_row_exists(conn, table: str, row_id: str) -> bool:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
|
||||
|
||||
|
||||
def clear_portal_workflow(conn) -> None:
|
||||
conn.execute(text("DELETE FROM bx_workflow_node"))
|
||||
conn.execute(text("DELETE FROM bx_workflow_def"))
|
||||
|
||||
|
||||
def transform_expense_item(
|
||||
row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"expense#{rid}"
|
||||
return {
|
||||
"id": rid,
|
||||
"parent_id": legacy_id_to_str(row.get("parent_id")) or "0",
|
||||
"item_name": (row.get("item_name") or "")[:128],
|
||||
"item_code": _str_or_none(row.get("item_code"), 64),
|
||||
"level": int(row.get("level") or 1),
|
||||
"sort_order": int(row.get("sort_order") or 0),
|
||||
"status": int(row.get("status") or 1),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_bx_user_id(row.get("update_by"), bx_user_map, admin_id, stats, label),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_workflow_def(row: dict, tenant_no: str, stats: MigrationStats) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
return {
|
||||
"id": rid,
|
||||
"biz_type": (row.get("biz_type") or "")[:32],
|
||||
"wf_name": (row.get("wf_name") or "")[:128],
|
||||
"status": int(row.get("status") or 1),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": None,
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_workflow_node(
|
||||
row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"wf_node#{rid}"
|
||||
return {
|
||||
"id": rid,
|
||||
"wf_id": legacy_id_to_str(row.get("wf_id")),
|
||||
"node_name": (row.get("node_name") or "")[:128],
|
||||
"node_order": int(row.get("node_order") or 0),
|
||||
"approver_id": map_bx_user_id(
|
||||
row.get("approver_id"), bx_user_map, admin_id, stats, label, required=True
|
||||
),
|
||||
"approver_name": (row.get("approver_name") or "")[:64],
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": None,
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": None,
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
def transform_advance_apply(
|
||||
row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"advance#{rid}"
|
||||
expense_item_id = legacy_id_to_str(row.get("expense_item_id"))
|
||||
return {
|
||||
"id": rid,
|
||||
"apply_code": (row.get("apply_code") or "")[:32],
|
||||
"applicant_id": map_bx_user_id(
|
||||
row.get("applicant_id"), bx_user_map, admin_id, stats, label, required=True
|
||||
),
|
||||
"applicant_name": (row.get("applicant_name") or "")[:64],
|
||||
"reason": (row.get("reason") or "")[:1024],
|
||||
"purpose": _str_or_none(row.get("purpose"), 1024),
|
||||
"expense_item_id": expense_item_id,
|
||||
"amount": _dec(row.get("amount")) or Decimal("0"),
|
||||
"status": (row.get("status") or "draft")[:32],
|
||||
"current_node": _str_or_none(row.get("current_node"), 128),
|
||||
"wf_id": legacy_id_to_str(row.get("wf_id")),
|
||||
"issue_status": _str_or_none(row.get("issue_status"), 32),
|
||||
"issue_time": row.get("issue_time"),
|
||||
"issue_user_id": map_bx_user_id(row.get("issue_user_id"), bx_user_map, admin_id, stats, label),
|
||||
"issue_user_name": _str_or_none(row.get("issue_user_name"), 64),
|
||||
"issue_remark": _str_or_none(row.get("issue_remark"), 512),
|
||||
"actual_amount": _dec(row.get("actual_amount")),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_bx_user_id(row.get("update_by"), bx_user_map, admin_id, stats, label),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_advance_approval(
|
||||
row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"advance_approval#{rid}"
|
||||
now = datetime.now()
|
||||
return {
|
||||
"id": rid,
|
||||
"apply_id": legacy_id_to_str(row.get("apply_id")),
|
||||
"node_order": int(row.get("node_order") or 0),
|
||||
"node_name": (row.get("node_name") or "")[:128],
|
||||
"approver_id": map_bx_user_id(
|
||||
row.get("approver_id"), bx_user_map, admin_id, stats, label, required=True
|
||||
),
|
||||
"approver_name": (row.get("approver_name") or "")[:64],
|
||||
"approval_status": (row.get("approval_status") or "")[:32],
|
||||
"approval_opinion": _str_or_none(row.get("approval_opinion"), 1024),
|
||||
"approval_time": row.get("approval_time") or now,
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": None,
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("approval_time") or now,
|
||||
"update_by": None,
|
||||
"update_time": row.get("approval_time") or now,
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
def transform_reimburse_apply(
|
||||
row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"reimburse#{rid}"
|
||||
apply_code = (row.get("apply_code") or "")[:32]
|
||||
try:
|
||||
reimburse_date, title_seq = parse_reimburse_meta(apply_code)
|
||||
except ValueError as exc:
|
||||
stats.warnings.append(f"{label}: {exc}")
|
||||
reimburse_date = (row.get("create_time") or datetime.now()).date()
|
||||
title_seq = int(rid or 0) % 1000 or 1
|
||||
return {
|
||||
"id": rid,
|
||||
"apply_code": apply_code,
|
||||
"title": (row.get("title") or "")[:256],
|
||||
"reimburse_date": reimburse_date,
|
||||
"title_seq": title_seq,
|
||||
"applicant_id": map_bx_user_id(
|
||||
row.get("applicant_id"), bx_user_map, admin_id, stats, label, required=True
|
||||
),
|
||||
"applicant_name": (row.get("applicant_name") or "")[:64],
|
||||
"expense_id": legacy_id_to_str(row.get("expense_id")),
|
||||
"expense_name": (row.get("expense_name") or "")[:128],
|
||||
"amount": _dec(row.get("amount")) or Decimal("0"),
|
||||
"invoice_count": int(row.get("invoice_count") or 0),
|
||||
"attach_count": int(row.get("attach_count") or 0),
|
||||
"status": (row.get("status") or "draft")[:32],
|
||||
"current_node": _str_or_none(row.get("current_node"), 128),
|
||||
"wf_id": legacy_id_to_str(row.get("wf_id")),
|
||||
"pay_status": _str_or_none(row.get("pay_status"), 32),
|
||||
"pay_time": row.get("pay_time"),
|
||||
"pay_user_id": map_bx_user_id(row.get("pay_user_id"), bx_user_map, admin_id, stats, label),
|
||||
"pay_user_name": _str_or_none(row.get("pay_user_name"), 64),
|
||||
"actual_amount": _dec(row.get("actual_amount")),
|
||||
"offset_advance_id": legacy_id_to_str(row.get("offset_advance_id")),
|
||||
"offset_amount": _dec(row.get("offset_amount")),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_bx_user_id(row.get("update_by"), bx_user_map, admin_id, stats, label),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_reimburse_detail(row: dict, tenant_no: str, stats: MigrationStats) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
now = row.get("create_time") or datetime.now()
|
||||
return {
|
||||
"id": rid,
|
||||
"reimburse_id": legacy_id_to_str(row.get("reimburse_id")),
|
||||
"expense_id": legacy_id_to_str(row.get("expense_id")),
|
||||
"expense_name": (row.get("expense_name") or "")[:128],
|
||||
"amount": _dec(row.get("amount")) or Decimal("0"),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": now,
|
||||
"update_by": None,
|
||||
"update_time": row.get("update_time") or now,
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_reimburse_approval(
|
||||
row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"reimburse_approval#{rid}"
|
||||
now = datetime.now()
|
||||
return {
|
||||
"id": rid,
|
||||
"apply_id": legacy_id_to_str(row.get("apply_id")),
|
||||
"node_order": int(row.get("node_order") or 0),
|
||||
"node_name": (row.get("node_name") or "")[:128],
|
||||
"approver_id": map_bx_user_id(
|
||||
row.get("approver_id"), bx_user_map, admin_id, stats, label, required=True
|
||||
),
|
||||
"approver_name": (row.get("approver_name") or "")[:64],
|
||||
"approval_status": (row.get("approval_status") or "")[:32],
|
||||
"approval_opinion": _str_or_none(row.get("approval_opinion"), 1024),
|
||||
"approval_time": row.get("approval_time") or now,
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": None,
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("approval_time") or now,
|
||||
"update_by": None,
|
||||
"update_time": row.get("approval_time") or now,
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
def transform_reimburse_offset(row: dict, tenant_no: str, stats: MigrationStats) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
now = row.get("create_time") or datetime.now()
|
||||
return {
|
||||
"id": rid,
|
||||
"reimburse_id": legacy_id_to_str(row.get("reimburse_id")),
|
||||
"advance_id": legacy_id_to_str(row.get("advance_id")),
|
||||
"offset_amount": _dec(row.get("offset_amount")) or Decimal("0"),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": now,
|
||||
"update_by": None,
|
||||
"update_time": now,
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
def transform_attachment(
|
||||
row: dict, tenant_no: str, bx_user_map: Dict[int, str], admin_id: Optional[str], stats: MigrationStats
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"attachment#{rid}"
|
||||
biz_id = legacy_id_to_str(row.get("biz_id")) or "0"
|
||||
return {
|
||||
"id": rid,
|
||||
"biz_type": (row.get("biz_type") or "")[:32],
|
||||
"biz_id": biz_id,
|
||||
"file_name": (row.get("file_name") or "")[:256],
|
||||
"file_path": (row.get("file_path") or "")[:512],
|
||||
"file_type": _str_or_none(row.get("file_type"), 128),
|
||||
"file_size": row.get("file_size"),
|
||||
"attach_type": (row.get("attach_type") or "attachment")[:32],
|
||||
"sort_order": int(row.get("sort_order") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": None,
|
||||
"create_by": map_bx_user_id(row.get("create_by"), bx_user_map, admin_id, stats, label),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": None,
|
||||
"update_time": row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
INSERT_SQL = {
|
||||
"bx_expense_item": text(
|
||||
"""
|
||||
INSERT INTO bx_expense_item (
|
||||
id, parent_id, item_name, item_code, level, sort_order, status,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :parent_id, :item_name, :item_code, :level, :sort_order, :status,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_workflow_def": text(
|
||||
"""
|
||||
INSERT INTO bx_workflow_def (
|
||||
id, biz_type, wf_name, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :biz_type, :wf_name, :status, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_workflow_node": text(
|
||||
"""
|
||||
INSERT INTO bx_workflow_node (
|
||||
id, wf_id, node_name, node_order, approver_id, approver_name,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :wf_id, :node_name, :node_order, :approver_id, :approver_name,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_advance_apply": text(
|
||||
"""
|
||||
INSERT INTO bx_advance_apply (
|
||||
id, apply_code, applicant_id, applicant_name, reason, purpose, expense_item_id,
|
||||
amount, status, current_node, wf_id, issue_status, issue_time, issue_user_id,
|
||||
issue_user_name, issue_remark, actual_amount, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :apply_code, :applicant_id, :applicant_name, :reason, :purpose, :expense_item_id,
|
||||
:amount, :status, :current_node, :wf_id, :issue_status, :issue_time, :issue_user_id,
|
||||
:issue_user_name, :issue_remark, :actual_amount, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_advance_approval": text(
|
||||
"""
|
||||
INSERT INTO bx_advance_approval (
|
||||
id, apply_id, node_order, node_name, approver_id, approver_name,
|
||||
approval_status, approval_opinion, approval_time, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :apply_id, :node_order, :node_name, :approver_id, :approver_name,
|
||||
:approval_status, :approval_opinion, :approval_time, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_reimburse_apply": text(
|
||||
"""
|
||||
INSERT INTO bx_reimburse_apply (
|
||||
id, apply_code, title, reimburse_date, title_seq, applicant_id, applicant_name,
|
||||
expense_id, expense_name, amount, invoice_count, attach_count, status, current_node,
|
||||
wf_id, pay_status, pay_time, pay_user_id, pay_user_name, actual_amount,
|
||||
offset_advance_id, offset_amount, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :apply_code, :title, :reimburse_date, :title_seq, :applicant_id, :applicant_name,
|
||||
:expense_id, :expense_name, :amount, :invoice_count, :attach_count, :status, :current_node,
|
||||
:wf_id, :pay_status, :pay_time, :pay_user_id, :pay_user_name, :actual_amount,
|
||||
:offset_advance_id, :offset_amount, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_reimburse_detail": text(
|
||||
"""
|
||||
INSERT INTO bx_reimburse_detail (
|
||||
id, reimburse_id, expense_id, expense_name, amount, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :reimburse_id, :expense_id, :expense_name, :amount, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_reimburse_approval": text(
|
||||
"""
|
||||
INSERT INTO bx_reimburse_approval (
|
||||
id, apply_id, node_order, node_name, approver_id, approver_name,
|
||||
approval_status, approval_opinion, approval_time, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :apply_id, :node_order, :node_name, :approver_id, :approver_name,
|
||||
:approval_status, :approval_opinion, :approval_time, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_reimburse_offset": text(
|
||||
"""
|
||||
INSERT INTO bx_reimburse_offset (
|
||||
id, reimburse_id, advance_id, offset_amount, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :reimburse_id, :advance_id, :offset_amount, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
"bx_attachment": text(
|
||||
"""
|
||||
INSERT INTO bx_attachment (
|
||||
id, biz_type, biz_id, file_name, file_path, file_type, file_size, attach_type,
|
||||
sort_order, tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :biz_type, :biz_id, :file_name, :file_path, :file_type, :file_size, :attach_type,
|
||||
:sort_order, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
TABLE_OPTIONS: Dict[str, Dict[str, Any]] = {
|
||||
"bx_expense_item": {"include_deleted": True},
|
||||
"bx_workflow_def": {"include_deleted": False},
|
||||
"bx_workflow_node": {"include_deleted": False, "no_del_flag": True},
|
||||
"bx_advance_apply": {"include_deleted": False},
|
||||
"bx_advance_approval": {"include_deleted": False, "no_del_flag": True},
|
||||
"bx_reimburse_apply": {"include_deleted": False},
|
||||
"bx_reimburse_detail": {"include_deleted": False},
|
||||
"bx_reimburse_approval": {"include_deleted": False, "no_del_flag": True},
|
||||
"bx_reimburse_offset": {"include_deleted": False, "no_del_flag": True},
|
||||
"bx_attachment": {"include_deleted": False},
|
||||
}
|
||||
|
||||
|
||||
def write_mapping_csv(table: str, rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
|
||||
w.writeheader()
|
||||
for row in rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
|
||||
|
||||
|
||||
def migrate_table(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
table: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
bx_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
dry_run: bool,
|
||||
replace_workflow: bool,
|
||||
) -> List[dict]:
|
||||
step = stats.step(table)
|
||||
opts = TABLE_OPTIONS.get(table, {})
|
||||
source_rows = fetch_source_rows(
|
||||
source_engine,
|
||||
table,
|
||||
include_deleted=bool(opts.get("include_deleted")),
|
||||
no_del_flag=bool(opts.get("no_del_flag")),
|
||||
)
|
||||
step.source_count = len(source_rows)
|
||||
|
||||
transformers: Dict[str, Callable[..., dict]] = {
|
||||
"bx_expense_item": lambda r: transform_expense_item(r, tenant_no, bx_user_map, admin_id, stats),
|
||||
"bx_workflow_def": lambda r: transform_workflow_def(r, tenant_no, stats),
|
||||
"bx_workflow_node": lambda r: transform_workflow_node(r, tenant_no, bx_user_map, admin_id, stats),
|
||||
"bx_advance_apply": lambda r: transform_advance_apply(r, tenant_no, bx_user_map, admin_id, stats),
|
||||
"bx_advance_approval": lambda r: transform_advance_approval(r, tenant_no, bx_user_map, admin_id, stats),
|
||||
"bx_reimburse_apply": lambda r: transform_reimburse_apply(r, tenant_no, bx_user_map, admin_id, stats),
|
||||
"bx_reimburse_detail": lambda r: transform_reimburse_detail(r, tenant_no, stats),
|
||||
"bx_reimburse_approval": lambda r: transform_reimburse_approval(r, tenant_no, bx_user_map, admin_id, stats),
|
||||
"bx_reimburse_offset": lambda r: transform_reimburse_offset(r, tenant_no, stats),
|
||||
"bx_attachment": lambda r: transform_attachment(r, tenant_no, bx_user_map, admin_id, stats),
|
||||
}
|
||||
payloads = [transformers[table](r) for r in source_rows]
|
||||
|
||||
if dry_run:
|
||||
step.inserted = len(payloads)
|
||||
return source_rows
|
||||
|
||||
with target_engine.begin() as conn:
|
||||
if replace_workflow and table == "bx_workflow_def":
|
||||
clear_portal_workflow(conn)
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, table, rid):
|
||||
step.skipped += 1
|
||||
else:
|
||||
conn.execute(INSERT_SQL[table], payload)
|
||||
step.inserted += 1
|
||||
return source_rows
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool, phase: str) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== 报销数据迁移 [{phase}] [{mode}] ===")
|
||||
for table in ALL_STEPS:
|
||||
s = stats.step(table)
|
||||
if s.source_count or s.inserted or s.skipped:
|
||||
print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:25]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 25:
|
||||
print(f" ... 另有 {len(stats.warnings) - 25} 条")
|
||||
|
||||
|
||||
def resolve_steps(phase: str, only_tables: Optional[List[str]]) -> List[str]:
|
||||
if phase == "all":
|
||||
steps = list(ALL_STEPS)
|
||||
else:
|
||||
steps = list(PHASE_STEPS.get(phase, ()))
|
||||
if not steps:
|
||||
raise SystemExit(f"未知 phase: {phase}")
|
||||
if only_tables:
|
||||
unknown = [t for t in only_tables if t not in ALL_STEPS]
|
||||
if unknown:
|
||||
raise SystemExit(f"未知表: {unknown}")
|
||||
steps = [t for t in steps if t in only_tables]
|
||||
return steps
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_dir: Path,
|
||||
dry_run: bool,
|
||||
phase: str,
|
||||
only_tables: Optional[List[str]],
|
||||
replace_workflow: bool,
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
bx_user_map, admin_id = load_bx_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
print(f"bx 用户映射: {len(bx_user_map)} 条; admin={admin_id or '未找到'}")
|
||||
|
||||
steps = resolve_steps(phase, only_tables)
|
||||
for table in steps:
|
||||
print(f"\n>> 迁移 {table} ...")
|
||||
source_rows = migrate_table(
|
||||
source_engine,
|
||||
target_engine,
|
||||
table,
|
||||
tenant_no=tenant_no,
|
||||
bx_user_map=bx_user_map,
|
||||
admin_id=admin_id,
|
||||
stats=stats,
|
||||
dry_run=dry_run,
|
||||
replace_workflow=replace_workflow,
|
||||
)
|
||||
if not dry_run and source_rows:
|
||||
write_mapping_csv(table, source_rows, mapping_dir / f"bx_{table.replace('bx_', '')}_id_mapping.csv")
|
||||
|
||||
print_report(stats, dry_run, phase)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate bx reimbursement data from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--phase", default="all", choices=["base", "advance", "reimburse", "attachment", "all"])
|
||||
parser.add_argument("--tables", default="", help="逗号分隔,限定当前 phase 内的表")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument(
|
||||
"--no-replace-workflow",
|
||||
action="store_true",
|
||||
help="不清理门户预置工作流(默认 base/all 时会清理并写入源库 id=1/2)",
|
||||
)
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
|
||||
replace_workflow = not args.no_replace_workflow
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
print(f"阶段: {args.phase}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
|
||||
dry_run=args.dry_run,
|
||||
phase=args.phase,
|
||||
only_tables=only_tables,
|
||||
replace_workflow=replace_workflow,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,15 +0,0 @@
|
||||
-- 报销管理菜单名称调整(与 web-admin sqxp.ts 对齐)
|
||||
-- 备用金发放管理 -> 备用金发放
|
||||
-- 支付管理(发放/冲抵) -> 支付发放冲抵
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '备用金发放',
|
||||
update_time = NOW()
|
||||
WHERE id = 'BX00000000000000000000000000009'
|
||||
AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '支付发放冲抵',
|
||||
update_time = NOW()
|
||||
WHERE id = 'BX00000000000000000000000000013'
|
||||
AND del_flag = 0;
|
||||
@@ -1,513 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 线索相关表迁入 shanxia2.0。
|
||||
|
||||
顺序:crm_clue → crm_clue_follow
|
||||
用户外键经 user_mapping 转换;附件 OSS ID → 目标 sys_oss 相对 URL。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_clue.py --dry-run --env-file .env.production
|
||||
venv/bin/python scripts/migrate_crm_clue.py --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
if str(MIGRATION_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MIGRATION_DIR))
|
||||
|
||||
from minio_url import resolve_oss_id_csv # noqa: E402
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
CUSTOMER_TYPE_MAP = {
|
||||
"company": "company",
|
||||
"person": "person",
|
||||
"未分类": None,
|
||||
"a类国央企单位": "A类国央企业",
|
||||
"A类国央企单位": "A类国央企业",
|
||||
"A类国央企业": "A类国央企业",
|
||||
"b类股份制企业": "B类股份制企业",
|
||||
"B类股份制企业": "B类股份制企业",
|
||||
"c类私营单位": "C类私营企业",
|
||||
"C类私营企业": "C类私营企业",
|
||||
"d外商投资企业": "D外商投资企业",
|
||||
"D外商投资企业": "D外商投资企业",
|
||||
"e类国资委企业": "5",
|
||||
"5": "5",
|
||||
}
|
||||
|
||||
FOLLOW_TYPE_MAP = {
|
||||
"电话": "phone",
|
||||
"phone": "phone",
|
||||
"微信": "wechat",
|
||||
"wechat": "wechat",
|
||||
"邮件": "email",
|
||||
"email": "email",
|
||||
"拜访": "visit",
|
||||
"visit": "visit",
|
||||
"其他": "other",
|
||||
"other": "other",
|
||||
}
|
||||
|
||||
MIGRATION_STEPS = (
|
||||
("crm_clue", "clue"),
|
||||
("crm_clue_follow", "clue_follow"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
steps: Dict[str, StepStats] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str) -> StepStats:
|
||||
if name not in self.steps:
|
||||
self.steps[name] = StepStats()
|
||||
return self.steps[name]
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
|
||||
return mapped
|
||||
|
||||
|
||||
def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
|
||||
with target_engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
|
||||
{"t": tenant_no},
|
||||
).mappings().all()
|
||||
return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
|
||||
|
||||
|
||||
def normalize_customer_type(raw: Any, stats: MigrationStats, label: str) -> Optional[str]:
|
||||
value = (str(raw).strip() if raw is not None else "") or ""
|
||||
if not value:
|
||||
return None
|
||||
if value in CUSTOMER_TYPE_MAP:
|
||||
mapped = CUSTOMER_TYPE_MAP[value]
|
||||
if mapped is None:
|
||||
stats.warnings.append(f"{label}: customer_type={value!r} 已置空")
|
||||
return mapped
|
||||
lowered = value.lower()
|
||||
for key, mapped in CUSTOMER_TYPE_MAP.items():
|
||||
if key.lower() == lowered:
|
||||
return mapped
|
||||
stats.warnings.append(f"{label}: customer_type={value!r} 未识别,已置空")
|
||||
return None
|
||||
|
||||
|
||||
def normalize_follow_type(raw: Any) -> str:
|
||||
value = (str(raw).strip() if raw is not None else "") or "other"
|
||||
return FOLLOW_TYPE_MAP.get(value, FOLLOW_TYPE_MAP.get(value.lower(), "other"))
|
||||
|
||||
|
||||
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s[:max_len] if max_len else s
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def target_row_exists(conn, table: str, row_id: str) -> bool:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
|
||||
|
||||
|
||||
def transform_clue(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"clue#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"clue_name": (row.get("clue_name") or "")[:100],
|
||||
"mobile": (row.get("mobile") or "")[:20],
|
||||
"company": _str_or_none(row.get("company"), 200),
|
||||
"customer_type": normalize_customer_type(row.get("customer_type"), stats, label),
|
||||
"telephone": _str_or_none(row.get("telephone"), 30),
|
||||
"email": _str_or_none(row.get("email"), 100),
|
||||
"title": _str_or_none(row.get("title"), 10),
|
||||
"position": _str_or_none(row.get("position"), 50),
|
||||
"source": (row.get("source") or "")[:20],
|
||||
"level": (row.get("level") or "unrated")[:10],
|
||||
"industry": _str_or_none(row.get("industry"), 50),
|
||||
"park_area": _str_or_none(row.get("park_area"), 100),
|
||||
"province": _str_or_none(row.get("province"), 50),
|
||||
"city": _str_or_none(row.get("city"), 50),
|
||||
"district": _str_or_none(row.get("district"), 50),
|
||||
"address": _str_or_none(row.get("address"), 255),
|
||||
"website": _str_or_none(row.get("website"), 500),
|
||||
"intent_products": _str_or_none(row.get("intent_products"), 500),
|
||||
"expect_time": row.get("expect_time"),
|
||||
"budget": _str_or_none(row.get("budget"), 20),
|
||||
"requirement": row.get("requirement"),
|
||||
"status": (row.get("status") or "new")[:20],
|
||||
"owner_user_id": map_user(row.get("owner_user_id")),
|
||||
"owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
|
||||
"last_time": row.get("last_time"),
|
||||
"next_time": row.get("next_time"),
|
||||
"follow_count": int(row.get("follow_count") or 0),
|
||||
"is_converted": int(row.get("is_converted") or 0),
|
||||
"converted_time": row.get("converted_time"),
|
||||
"customer_id": legacy_id_to_str(row.get("customer_id")),
|
||||
"converted_by": map_user(row.get("converted_by")),
|
||||
"assign_time": row.get("assign_time"),
|
||||
"assign_by": map_user(row.get("assign_by")),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_clue_follow(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"clue_follow#{rid}"
|
||||
owner_id = resolve_portal_user_id(row.get("create_by"), crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"clue_id": legacy_id_to_str(row.get("clue_id")),
|
||||
"clue_name": _str_or_none(row.get("clue_name"), 100),
|
||||
"follow_type": normalize_follow_type(row.get("follow_type")),
|
||||
"follow_content": row.get("follow_content") or "",
|
||||
"next_time": row.get("next_time"),
|
||||
"attachments": resolve_oss_id_csv(row.get("attachments"), oss_map) or None,
|
||||
"create_by_name": _str_or_none(row.get("create_by_name"), 50),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": owner_id,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": resolve_portal_user_id(row.get("update_by"), crm_user_map, admin_id, stats, label),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
INSERT_CLUE_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_clue (
|
||||
id, clue_name, mobile, company, customer_type, telephone, email, title, position,
|
||||
source, level, industry, park_area, province, city, district, address, website,
|
||||
intent_products, expect_time, budget, requirement, status, owner_user_id, owner_user_name,
|
||||
last_time, next_time, follow_count, is_converted, converted_time, customer_id,
|
||||
converted_by, assign_time, assign_by, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :clue_name, :mobile, :company, :customer_type, :telephone, :email, :title, :position,
|
||||
:source, :level, :industry, :park_area, :province, :city, :district, :address, :website,
|
||||
:intent_products, :expect_time, :budget, :requirement, :status, :owner_user_id, :owner_user_name,
|
||||
:last_time, :next_time, :follow_count, :is_converted, :converted_time, :customer_id,
|
||||
:converted_by, :assign_time, :assign_by, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_CLUE_FOLLOW_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_clue_follow (
|
||||
id, clue_id, clue_name, follow_type, follow_content, next_time, attachments,
|
||||
create_by_name, tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :clue_id, :clue_name, :follow_type, :follow_content, :next_time, :attachments,
|
||||
:create_by_name, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
STEP_INSERT_SQL = {
|
||||
"crm_clue": INSERT_CLUE_SQL,
|
||||
"crm_clue_follow": INSERT_CLUE_FOLLOW_SQL,
|
||||
}
|
||||
|
||||
|
||||
def write_mapping_csv(source_rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
|
||||
w.writeheader()
|
||||
for row in source_rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
|
||||
|
||||
|
||||
def migrate_table(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
table: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
) -> List[dict]:
|
||||
step = stats.step(table)
|
||||
source_rows = fetch_source_rows(source_engine, table, include_deleted)
|
||||
step.source_count = len(source_rows)
|
||||
|
||||
transformers = {
|
||||
"crm_clue": lambda r: transform_clue(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_clue_follow": lambda r: transform_clue_follow(
|
||||
r, tenant_no, crm_user_map, admin_id, oss_map, stats
|
||||
),
|
||||
}
|
||||
payloads = [transformers[table](r) for r in source_rows]
|
||||
|
||||
if dry_run:
|
||||
step.inserted = len(payloads)
|
||||
return source_rows
|
||||
|
||||
insert_sql = STEP_INSERT_SQL[table]
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, table, rid):
|
||||
if update_existing:
|
||||
step.skipped += 1
|
||||
else:
|
||||
step.skipped += 1
|
||||
else:
|
||||
conn.execute(insert_sql, payload)
|
||||
step.inserted += 1
|
||||
return source_rows
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 线索表迁移 [{mode}] ===")
|
||||
for table, _ in MIGRATION_STEPS:
|
||||
s = stats.step(table)
|
||||
print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:20]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_dir: Path,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
only_tables: Optional[List[str]],
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
oss_map = load_oss_id_to_url(target_engine, tenant_no)
|
||||
print(f"sys_oss 映射条数: {len(oss_map)}")
|
||||
|
||||
selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
|
||||
for table in selected:
|
||||
print(f"\n>> 迁移 {table} ...")
|
||||
source_rows = migrate_table(
|
||||
source_engine,
|
||||
target_engine,
|
||||
table,
|
||||
tenant_no=tenant_no,
|
||||
crm_user_map=crm_user_map,
|
||||
admin_id=admin_id,
|
||||
oss_map=oss_map,
|
||||
stats=stats,
|
||||
dry_run=dry_run,
|
||||
update_existing=update_existing,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
if table == "crm_clue" and not dry_run:
|
||||
write_mapping_csv(source_rows, mapping_dir / "crm_clue_id_mapping.csv")
|
||||
|
||||
print_report(stats, dry_run)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM clue tables from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true")
|
||||
parser.add_argument("--include-deleted", action="store_true")
|
||||
parser.add_argument("--tables", default="", help="逗号分隔,默认全部两表")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
include_deleted=args.include_deleted,
|
||||
only_tables=only_tables,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,840 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 合同相关表迁入 shanxia2.0。
|
||||
|
||||
顺序:crm_contract_template → crm_contract → crm_contract_product →
|
||||
crm_contract_product_price_tier → crm_contract_approval → crm_contract_change
|
||||
用户外键经 user_mapping;附件 OSS ID / URL 规范化;attachment_files JSON 保留 ossId。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_contract.py --dry-run --env-file .env.production
|
||||
venv/bin/python scripts/migrate_crm_contract.py --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
if str(MIGRATION_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MIGRATION_DIR))
|
||||
|
||||
from minio_url import extract_minio_object_key, join_relative_urls, resolve_oss_id_csv # noqa: E402
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
MIGRATION_STEPS = (
|
||||
("crm_contract_template", "contract_template"),
|
||||
("crm_contract", "contract"),
|
||||
("crm_contract_product", "contract_product"),
|
||||
("crm_contract_product_price_tier", "contract_product_price_tier"),
|
||||
("crm_contract_approval", "contract_approval"),
|
||||
("crm_contract_change", "contract_change"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
steps: Dict[str, StepStats] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str) -> StepStats:
|
||||
if name not in self.steps:
|
||||
self.steps[name] = StepStats()
|
||||
return self.steps[name]
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return admin_id if required else None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return admin_id if required else None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
|
||||
return admin_id if required else None
|
||||
return mapped
|
||||
|
||||
|
||||
def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
|
||||
with target_engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
|
||||
{"t": tenant_no},
|
||||
).mappings().all()
|
||||
return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
|
||||
|
||||
|
||||
def normalize_attachment_csv(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
oss_resolved = resolve_oss_id_csv(raw, oss_map)
|
||||
if oss_resolved:
|
||||
return oss_resolved
|
||||
parts: List[str] = []
|
||||
for token in str(raw).split(","):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
if token.isdigit() and token in oss_map:
|
||||
parts.append(oss_map[token])
|
||||
else:
|
||||
key = extract_minio_object_key(token)
|
||||
if key:
|
||||
parts.append(key)
|
||||
joined = join_relative_urls(parts)
|
||||
return joined or None
|
||||
|
||||
|
||||
def normalize_contract_attachment_files(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
value = str(raw).strip()
|
||||
if value.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, list):
|
||||
out: List[dict] = []
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
oss_id = legacy_id_to_str(item.get("ossId") or item.get("oss_id"))
|
||||
if not oss_id:
|
||||
continue
|
||||
out.append({"ossId": oss_id, "category": item.get("category") or "other"})
|
||||
if out:
|
||||
return json.dumps(out, ensure_ascii=False)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return normalize_attachment_csv(raw, oss_map)
|
||||
|
||||
|
||||
def normalize_file_path(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
value = str(raw).strip()
|
||||
if value.isdigit() and value in oss_map:
|
||||
return oss_map[value]
|
||||
key = extract_minio_object_key(value)
|
||||
return key or None
|
||||
|
||||
|
||||
def _dec(value: Any, default: Optional[Decimal] = None) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s[:max_len] if max_len else s
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def target_row_exists(conn, table: str, row_id: str) -> bool:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
|
||||
|
||||
|
||||
def transform_contract_template(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"contract_template#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"template_no": (row.get("template_no") or "")[:50],
|
||||
"template_name": (row.get("template_name") or "")[:200],
|
||||
"contract_type": _str_or_none(row.get("contract_type"), 20),
|
||||
"template_content": row.get("template_content"),
|
||||
"payment_terms": row.get("payment_terms"),
|
||||
"delivery_terms": row.get("delivery_terms"),
|
||||
"contract_terms": row.get("contract_terms"),
|
||||
"party_b": _str_or_none(row.get("party_b"), 200),
|
||||
"party_b_address": _str_or_none(row.get("party_b_address"), 255),
|
||||
"party_b_contact": _str_or_none(row.get("party_b_contact"), 50),
|
||||
"party_b_mobile": _str_or_none(row.get("party_b_mobile"), 20),
|
||||
"default_currency": (row.get("default_currency") or "CNY")[:10],
|
||||
"default_tax_rate": _dec(row.get("default_tax_rate"), Decimal("6.00")),
|
||||
"default_period": int(row.get("default_period") or 12),
|
||||
"is_active": int(row.get("is_active") if row.get("is_active") is not None else 1),
|
||||
"usage_count": int(row.get("usage_count") or 0),
|
||||
"sort": int(row.get("sort") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_contract(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"contract#{rid}"
|
||||
|
||||
def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
|
||||
|
||||
owner_id = map_user(row.get("owner_user_id"), required=True)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"contract_no": (row.get("contract_no") or "")[:50],
|
||||
"contract_name": (row.get("contract_name") or "")[:200],
|
||||
"customer_id": legacy_id_to_str(row.get("customer_id")) or "",
|
||||
"customer_name": _str_or_none(row.get("customer_name"), 200),
|
||||
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")),
|
||||
"quote_id": legacy_id_to_str(row.get("quote_id")),
|
||||
"parent_contract_id": legacy_id_to_str(row.get("parent_contract_id")),
|
||||
"renewal_count": int(row.get("renewal_count") or 0),
|
||||
"template_id": legacy_id_to_str(row.get("template_id")),
|
||||
"contract_template": (row.get("contract_template") or "shanxia")[:50],
|
||||
"contact_id": legacy_id_to_str(row.get("contact_id")),
|
||||
"contact_name": _str_or_none(row.get("contact_name"), 50),
|
||||
"contact_mobile": _str_or_none(row.get("contact_mobile"), 20),
|
||||
"contract_type": (row.get("contract_type") or "sale")[:20],
|
||||
"contract_nature": (row.get("contract_nature") or "new")[:20],
|
||||
"amount": _dec(row.get("amount"), Decimal("0.00")),
|
||||
"currency": (row.get("currency") or "CNY")[:10],
|
||||
"tax_rate": _dec(row.get("tax_rate"), Decimal("6.00")),
|
||||
"amount_with_tax": _dec(row.get("amount_with_tax"), Decimal("0.00")),
|
||||
"status": (row.get("status") or "draft")[:20],
|
||||
"performance_status": (row.get("performance_status") or "normal")[:20],
|
||||
"performance_rate": _dec(row.get("performance_rate"), Decimal("0.00")),
|
||||
"customer_satisfaction": int(row["customer_satisfaction"]) if row.get("customer_satisfaction") is not None else None,
|
||||
"approval_status": _str_or_none(row.get("approval_status"), 20),
|
||||
"approval_flow_id": legacy_id_to_str(row.get("approval_flow_id")),
|
||||
"contract_file": normalize_file_path(row.get("contract_file"), oss_map),
|
||||
"attachment_files": normalize_contract_attachment_files(row.get("attachment_files"), oss_map),
|
||||
"sign_date": row.get("sign_date"),
|
||||
"actual_sign_date": row.get("actual_sign_date"),
|
||||
"start_date": row.get("start_date"),
|
||||
"end_date": row.get("end_date"),
|
||||
"contract_period": int(row["contract_period"]) if row.get("contract_period") is not None else None,
|
||||
"owner_user_id": owner_id or "",
|
||||
"owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
|
||||
"received_amount": _dec(row.get("received_amount"), Decimal("0.00")),
|
||||
"unreceived_amount": _dec(row.get("unreceived_amount"), Decimal("0.00")),
|
||||
"invoice_amount": _dec(row.get("invoice_amount"), Decimal("0.00")),
|
||||
"uninvoice_amount": _dec(row.get("uninvoice_amount"), Decimal("0.00")),
|
||||
"party_a": _str_or_none(row.get("party_a"), 200),
|
||||
"party_a_address": _str_or_none(row.get("party_a_address"), 255),
|
||||
"party_a_contact": _str_or_none(row.get("party_a_contact"), 50),
|
||||
"party_a_mobile": _str_or_none(row.get("party_a_mobile"), 20),
|
||||
"party_a_legal_person": _str_or_none(row.get("party_a_legal_person"), 50),
|
||||
"party_a_agent": _str_or_none(row.get("party_a_agent"), 50),
|
||||
"party_a_phone_fax": _str_or_none(row.get("party_a_phone_fax"), 50),
|
||||
"party_a_bank": _str_or_none(row.get("party_a_bank"), 100),
|
||||
"party_a_account": _str_or_none(row.get("party_a_account"), 50),
|
||||
"party_b": _str_or_none(row.get("party_b"), 200),
|
||||
"party_b_address": _str_or_none(row.get("party_b_address"), 255),
|
||||
"party_b_contact": _str_or_none(row.get("party_b_contact"), 50),
|
||||
"party_b_mobile": _str_or_none(row.get("party_b_mobile"), 20),
|
||||
"party_b_legal_person": _str_or_none(row.get("party_b_legal_person"), 100),
|
||||
"party_b_agent": _str_or_none(row.get("party_b_agent"), 100),
|
||||
"party_b_phone_fax": _str_or_none(row.get("party_b_phone_fax"), 100),
|
||||
"party_b_bank": _str_or_none(row.get("party_b_bank"), 200),
|
||||
"party_b_account": _str_or_none(row.get("party_b_account"), 100),
|
||||
"payment_terms": row.get("payment_terms"),
|
||||
"delivery_terms": row.get("delivery_terms"),
|
||||
"contract_terms": row.get("contract_terms"),
|
||||
"settlement_method": _str_or_none(row.get("settlement_method"), 50),
|
||||
"template_content": row.get("template_content"),
|
||||
"settlement_clause_drafts": row.get("settlement_clause_drafts"),
|
||||
"invalid_reason": _str_or_none(row.get("invalid_reason"), 500),
|
||||
"invalid_time": row.get("invalid_time"),
|
||||
"version_no": int(row.get("version_no") if row.get("version_no") is not None else row.get("version") or 0),
|
||||
"supplier_id": legacy_id_to_str(row.get("supplier_id")),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_contract_product(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"contract_product#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
||||
"product_id": legacy_id_to_str(row.get("product_id")) or "",
|
||||
"product_name": (row.get("product_name") or "")[:200],
|
||||
"product_no": _str_or_none(row.get("product_no"), 50),
|
||||
"category_id": legacy_id_to_str(row.get("category_id")),
|
||||
"category_name": _str_or_none(row.get("category_name"), 100),
|
||||
"model": _str_or_none(row.get("model"), 100),
|
||||
"unit": (row.get("unit") or "件")[:20],
|
||||
"quantity": _dec(row.get("quantity"), Decimal("0")),
|
||||
"price": _dec(row.get("price"), Decimal("0.00")),
|
||||
"discount": _dec(row.get("discount"), Decimal("0.00")),
|
||||
"tax_rate": _dec(row.get("tax_rate"), Decimal("6.00")),
|
||||
"tax_amount": _dec(row.get("tax_amount"), Decimal("0.00")),
|
||||
"subtotal": _dec(row.get("subtotal"), Decimal("0.00")),
|
||||
"delivery_date": row.get("delivery_date"),
|
||||
"delivery_status": (row.get("delivery_status") or "pending")[:20],
|
||||
"delivered_quantity": _dec(row.get("delivered_quantity"), Decimal("0")),
|
||||
"description": _str_or_none(row.get("description"), 1000),
|
||||
"package_spec": _str_or_none(row.get("package_spec"), 200),
|
||||
"delivery_method": _str_or_none(row.get("delivery_method"), 100),
|
||||
"sort": int(row.get("sort") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_contract_price_tier(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"contract_price_tier#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
letter_oss_ids = row.get("letter_oss_ids")
|
||||
letter_oss_id = legacy_id_to_str(row.get("letter_oss_id"))
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
||||
"contract_product_id": legacy_id_to_str(row.get("contract_product_id")) or "",
|
||||
"start_date": row.get("start_date"),
|
||||
"end_date": row.get("end_date"),
|
||||
"price": _dec(row.get("price"), Decimal("0")),
|
||||
"letter_oss_ids": letter_oss_ids if letter_oss_ids else None,
|
||||
"letter_oss_id": letter_oss_id,
|
||||
"sort": int(row.get("sort") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_contract_approval(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"contract_approval#{rid}"
|
||||
|
||||
def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
||||
"flow_id": legacy_id_to_str(row.get("flow_id")),
|
||||
"step": int(row.get("step") or 1),
|
||||
"step_name": _str_or_none(row.get("step_name"), 100),
|
||||
"approver_id": map_user(row.get("approver_id"), required=True) or "",
|
||||
"approver_name": _str_or_none(row.get("approver_name"), 50),
|
||||
"approver_dept": _str_or_none(row.get("approver_dept"), 100),
|
||||
"approval_result": (row.get("approval_result") or "pending")[:20],
|
||||
"approval_opinion": row.get("approval_opinion"),
|
||||
"approval_time": row.get("approval_time"),
|
||||
"approval_duration": int(row["approval_duration"]) if row.get("approval_duration") is not None else None,
|
||||
"attachments": normalize_attachment_csv(row.get("attachments"), oss_map),
|
||||
"is_current": int(row.get("is_current") if row.get("is_current") is not None else 1),
|
||||
"sort": int(row.get("sort") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_contract_change(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"contract_change#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"contract_id": legacy_id_to_str(row.get("contract_id")) or "",
|
||||
"contract_no": _str_or_none(row.get("contract_no"), 50),
|
||||
"change_type": (row.get("change_type") or "")[:50],
|
||||
"change_reason": (row.get("change_reason") or "")[:500],
|
||||
"change_content": row.get("change_content"),
|
||||
"old_value": row.get("old_value"),
|
||||
"new_value": row.get("new_value"),
|
||||
"change_amount": _dec(row.get("change_amount")),
|
||||
"approval_status": (row.get("approval_status") or "pending")[:20],
|
||||
"approver_id": map_user(row.get("approver_id")),
|
||||
"approver_name": _str_or_none(row.get("approver_name"), 50),
|
||||
"approval_time": row.get("approval_time"),
|
||||
"approval_opinion": _str_or_none(row.get("approval_opinion"), 500),
|
||||
"attachment_files": normalize_contract_attachment_files(row.get("attachment_files"), oss_map),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
INSERT_CONTRACT_TEMPLATE_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_contract_template (
|
||||
id, template_no, template_name, contract_type, template_content, payment_terms,
|
||||
delivery_terms, contract_terms, party_b, party_b_address, party_b_contact, party_b_mobile,
|
||||
default_currency, default_tax_rate, default_period, is_active, usage_count, sort,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :template_no, :template_name, :contract_type, :template_content, :payment_terms,
|
||||
:delivery_terms, :contract_terms, :party_b, :party_b_address, :party_b_contact, :party_b_mobile,
|
||||
:default_currency, :default_tax_rate, :default_period, :is_active, :usage_count, :sort,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_CONTRACT_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_contract (
|
||||
id, contract_no, contract_name, customer_id, customer_name, opportunity_id, quote_id,
|
||||
parent_contract_id, renewal_count, template_id, contract_template, contact_id, contact_name,
|
||||
contact_mobile, contract_type, contract_nature, amount, currency, tax_rate, amount_with_tax,
|
||||
status, performance_status, performance_rate, customer_satisfaction, approval_status,
|
||||
approval_flow_id, contract_file, attachment_files, sign_date, actual_sign_date, start_date,
|
||||
end_date, contract_period, owner_user_id, owner_user_name, received_amount, unreceived_amount,
|
||||
invoice_amount, uninvoice_amount, party_a, party_a_address, party_a_contact, party_a_mobile,
|
||||
party_a_legal_person, party_a_agent, party_a_phone_fax, party_a_bank, party_a_account,
|
||||
party_b, party_b_address, party_b_contact, party_b_mobile, party_b_legal_person, party_b_agent,
|
||||
party_b_phone_fax, party_b_bank, party_b_account, payment_terms, delivery_terms, contract_terms,
|
||||
settlement_method, template_content, settlement_clause_drafts, invalid_reason, invalid_time,
|
||||
version_no, supplier_id, tenant_no, order_num, remark, create_by, create_dept_id, create_time,
|
||||
update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :contract_no, :contract_name, :customer_id, :customer_name, :opportunity_id, :quote_id,
|
||||
:parent_contract_id, :renewal_count, :template_id, :contract_template, :contact_id, :contact_name,
|
||||
:contact_mobile, :contract_type, :contract_nature, :amount, :currency, :tax_rate, :amount_with_tax,
|
||||
:status, :performance_status, :performance_rate, :customer_satisfaction, :approval_status,
|
||||
:approval_flow_id, :contract_file, :attachment_files, :sign_date, :actual_sign_date, :start_date,
|
||||
:end_date, :contract_period, :owner_user_id, :owner_user_name, :received_amount, :unreceived_amount,
|
||||
:invoice_amount, :uninvoice_amount, :party_a, :party_a_address, :party_a_contact, :party_a_mobile,
|
||||
:party_a_legal_person, :party_a_agent, :party_a_phone_fax, :party_a_bank, :party_a_account,
|
||||
:party_b, :party_b_address, :party_b_contact, :party_b_mobile, :party_b_legal_person, :party_b_agent,
|
||||
:party_b_phone_fax, :party_b_bank, :party_b_account, :payment_terms, :delivery_terms, :contract_terms,
|
||||
:settlement_method, :template_content, :settlement_clause_drafts, :invalid_reason, :invalid_time,
|
||||
:version_no, :supplier_id, :tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time,
|
||||
:update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_CONTRACT_PRODUCT_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_contract_product (
|
||||
id, contract_id, product_id, product_name, product_no, category_id, category_name,
|
||||
model, unit, quantity, price, discount, tax_rate, tax_amount, subtotal,
|
||||
delivery_date, delivery_status, delivered_quantity, description, package_spec,
|
||||
delivery_method, sort, tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :contract_id, :product_id, :product_name, :product_no, :category_id, :category_name,
|
||||
:model, :unit, :quantity, :price, :discount, :tax_rate, :tax_amount, :subtotal,
|
||||
:delivery_date, :delivery_status, :delivered_quantity, :description, :package_spec,
|
||||
:delivery_method, :sort, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_CONTRACT_PRICE_TIER_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_contract_product_price_tier (
|
||||
id, contract_id, contract_product_id, start_date, end_date, price,
|
||||
letter_oss_ids, letter_oss_id, sort, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :contract_id, :contract_product_id, :start_date, :end_date, :price,
|
||||
:letter_oss_ids, :letter_oss_id, :sort, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_CONTRACT_APPROVAL_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_contract_approval (
|
||||
id, contract_id, flow_id, step, step_name, approver_id, approver_name, approver_dept,
|
||||
approval_result, approval_opinion, approval_time, approval_duration, attachments,
|
||||
is_current, sort, tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :contract_id, :flow_id, :step, :step_name, :approver_id, :approver_name, :approver_dept,
|
||||
:approval_result, :approval_opinion, :approval_time, :approval_duration, :attachments,
|
||||
:is_current, :sort, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_CONTRACT_CHANGE_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_contract_change (
|
||||
id, contract_id, contract_no, change_type, change_reason, change_content, old_value,
|
||||
new_value, change_amount, approval_status, approver_id, approver_name, approval_time,
|
||||
approval_opinion, attachment_files, tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :contract_id, :contract_no, :change_type, :change_reason, :change_content, :old_value,
|
||||
:new_value, :change_amount, :approval_status, :approver_id, :approver_name, :approval_time,
|
||||
:approval_opinion, :attachment_files, :tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
STEP_INSERT_SQL = {
|
||||
"crm_contract_template": INSERT_CONTRACT_TEMPLATE_SQL,
|
||||
"crm_contract": INSERT_CONTRACT_SQL,
|
||||
"crm_contract_product": INSERT_CONTRACT_PRODUCT_SQL,
|
||||
"crm_contract_product_price_tier": INSERT_CONTRACT_PRICE_TIER_SQL,
|
||||
"crm_contract_approval": INSERT_CONTRACT_APPROVAL_SQL,
|
||||
"crm_contract_change": INSERT_CONTRACT_CHANGE_SQL,
|
||||
}
|
||||
|
||||
|
||||
def write_mapping_csv(source_rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
|
||||
w.writeheader()
|
||||
for row in source_rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
|
||||
|
||||
|
||||
def migrate_table(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
table: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
) -> List[dict]:
|
||||
step = stats.step(table)
|
||||
source_rows = fetch_source_rows(source_engine, table, include_deleted)
|
||||
step.source_count = len(source_rows)
|
||||
|
||||
transformers = {
|
||||
"crm_contract_template": lambda r: transform_contract_template(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_contract": lambda r: transform_contract(r, tenant_no, crm_user_map, admin_id, oss_map, stats),
|
||||
"crm_contract_product": lambda r: transform_contract_product(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_contract_product_price_tier": lambda r: transform_contract_price_tier(
|
||||
r, tenant_no, crm_user_map, admin_id, stats
|
||||
),
|
||||
"crm_contract_approval": lambda r: transform_contract_approval(
|
||||
r, tenant_no, crm_user_map, admin_id, oss_map, stats
|
||||
),
|
||||
"crm_contract_change": lambda r: transform_contract_change(
|
||||
r, tenant_no, crm_user_map, admin_id, oss_map, stats
|
||||
),
|
||||
}
|
||||
payloads = [transformers[table](r) for r in source_rows]
|
||||
|
||||
if dry_run:
|
||||
step.inserted = len(payloads)
|
||||
return source_rows
|
||||
|
||||
insert_sql = STEP_INSERT_SQL[table]
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, table, rid):
|
||||
if update_existing:
|
||||
step.skipped += 1
|
||||
else:
|
||||
step.skipped += 1
|
||||
else:
|
||||
conn.execute(insert_sql, payload)
|
||||
step.inserted += 1
|
||||
return source_rows
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 合同迁移 [{mode}] ===")
|
||||
for table, _ in MIGRATION_STEPS:
|
||||
s = stats.step(table)
|
||||
print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:20]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_dir: Path,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
only_tables: Optional[List[str]],
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
oss_map = load_oss_id_to_url(target_engine, tenant_no)
|
||||
print(f"sys_oss 映射条数: {len(oss_map)}")
|
||||
|
||||
selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
|
||||
for table in selected:
|
||||
print(f"\n>> 迁移 {table} ...")
|
||||
source_rows = migrate_table(
|
||||
source_engine,
|
||||
target_engine,
|
||||
table,
|
||||
tenant_no=tenant_no,
|
||||
crm_user_map=crm_user_map,
|
||||
admin_id=admin_id,
|
||||
oss_map=oss_map,
|
||||
stats=stats,
|
||||
dry_run=dry_run,
|
||||
update_existing=update_existing,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
if table == "crm_contract" and not dry_run:
|
||||
write_mapping_csv(source_rows, mapping_dir / "crm_contract_id_mapping.csv")
|
||||
|
||||
print_report(stats, dry_run)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM contract tables from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true")
|
||||
parser.add_argument("--include-deleted", action="store_true")
|
||||
parser.add_argument("--tables", default="", help="逗号分隔,默认全部六表")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
include_deleted=args.include_deleted,
|
||||
only_tables=only_tables,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,655 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 客户相关表迁入 shanxia2.0。
|
||||
|
||||
顺序:crm_customer → crm_contact → crm_customer_follow → crm_customer_pool
|
||||
证照字段 OSS ID → 目标 sys_oss 相对 URL;用户外键经 user_mapping 转换。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_customer.py --dry-run --env-file .env.production
|
||||
venv/bin/python scripts/migrate_crm_customer.py --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
if str(MIGRATION_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MIGRATION_DIR))
|
||||
|
||||
from minio_url import resolve_oss_id_csv # noqa: E402
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
CUSTOMER_TYPE_MAP = {
|
||||
"company": "company",
|
||||
"person": "person",
|
||||
"未分类": None,
|
||||
"a类国央企单位": "A类国央企业",
|
||||
"A类国央企单位": "A类国央企业",
|
||||
"A类国央企业": "A类国央企业",
|
||||
"b类股份制企业": "B类股份制企业",
|
||||
"B类股份制企业": "B类股份制企业",
|
||||
"c类私营单位": "C类私营企业",
|
||||
"C类私营企业": "C类私营企业",
|
||||
"d外商投资企业": "D外商投资企业",
|
||||
"D外商投资企业": "D外商投资企业",
|
||||
"e类国资委企业": "5",
|
||||
"5": "5",
|
||||
}
|
||||
|
||||
FOLLOW_TYPE_MAP = {
|
||||
"电话": "phone",
|
||||
"phone": "phone",
|
||||
"微信": "wechat",
|
||||
"wechat": "wechat",
|
||||
"邮件": "email",
|
||||
"email": "email",
|
||||
"拜访": "visit",
|
||||
"visit": "visit",
|
||||
"其他": "other",
|
||||
"other": "other",
|
||||
}
|
||||
|
||||
MIGRATION_STEPS = (
|
||||
("crm_customer", "customer"),
|
||||
("crm_contact", "contact"),
|
||||
("crm_customer_follow", "follow"),
|
||||
("crm_customer_pool", "pool"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
steps: Dict[str, StepStats] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str) -> StepStats:
|
||||
if name not in self.steps:
|
||||
self.steps[name] = StepStats()
|
||||
return self.steps[name]
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
msg = f"{label}: crm_user_id={uid} 无映射"
|
||||
if required:
|
||||
stats.warnings.append(msg)
|
||||
return mapped
|
||||
|
||||
|
||||
def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
|
||||
with target_engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
|
||||
{"t": tenant_no},
|
||||
).mappings().all()
|
||||
return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
|
||||
|
||||
|
||||
def normalize_customer_type(raw: Any, stats: MigrationStats, label: str) -> Optional[str]:
|
||||
value = (str(raw).strip() if raw is not None else "") or ""
|
||||
if not value:
|
||||
return None
|
||||
if value in CUSTOMER_TYPE_MAP:
|
||||
mapped = CUSTOMER_TYPE_MAP[value]
|
||||
if mapped is None:
|
||||
stats.warnings.append(f"{label}: customer_type={value!r} 已置空")
|
||||
return mapped
|
||||
lowered = value.lower()
|
||||
for key, mapped in CUSTOMER_TYPE_MAP.items():
|
||||
if key.lower() == lowered:
|
||||
return mapped
|
||||
stats.warnings.append(f"{label}: customer_type={value!r} 未识别,已置空")
|
||||
return None
|
||||
|
||||
|
||||
def normalize_follow_type(raw: Any) -> str:
|
||||
value = (str(raw).strip() if raw is not None else "") or "other"
|
||||
return FOLLOW_TYPE_MAP.get(value, FOLLOW_TYPE_MAP.get(value.lower(), "other"))
|
||||
|
||||
|
||||
def _dec(value: Any) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s[:max_len] if max_len else s
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def target_row_exists(conn, table: str, row_id: str) -> bool:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
|
||||
|
||||
|
||||
def transform_customer(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"customer#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, f"{label} {field_val}")
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"customer_no": (row.get("customer_no") or "")[:50],
|
||||
"customer_name": (row.get("customer_name") or "")[:200],
|
||||
"short_name": _str_or_none(row.get("short_name"), 100),
|
||||
"customer_type": normalize_customer_type(row.get("customer_type"), stats, label),
|
||||
"mobile": _str_or_none(row.get("mobile"), 20),
|
||||
"telephone": _str_or_none(row.get("telephone"), 30),
|
||||
"email": _str_or_none(row.get("email"), 100),
|
||||
"website": _str_or_none(row.get("website"), 500),
|
||||
"level": (row.get("level") or "normal")[:20],
|
||||
"source": _str_or_none(row.get("source"), 20),
|
||||
"industry": _str_or_none(row.get("industry"), 50),
|
||||
"park_area": _str_or_none(row.get("park_area"), 100),
|
||||
"payment_terms": _str_or_none(row.get("payment_terms"), 500),
|
||||
"scale": _str_or_none(row.get("scale"), 20),
|
||||
"province": _str_or_none(row.get("province"), 50),
|
||||
"city": _str_or_none(row.get("city"), 50),
|
||||
"district": _str_or_none(row.get("district"), 50),
|
||||
"address": _str_or_none(row.get("address"), 255),
|
||||
"status": (row.get("status") or "normal")[:20],
|
||||
"deal_status": (row.get("deal_status") or "not_deal")[:20],
|
||||
"owner_user_id": map_user(row.get("owner_user_id")),
|
||||
"owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
|
||||
"last_time": row.get("last_time"),
|
||||
"next_time": row.get("next_time"),
|
||||
"follow_count": int(row.get("follow_count") or 0),
|
||||
"deal_time": row.get("deal_time"),
|
||||
"deal_amount": _dec(row.get("deal_amount")) or Decimal("0.00"),
|
||||
"contact_count": int(row.get("contact_count") or 0),
|
||||
"opportunity_count": int(row.get("opportunity_count") or 0),
|
||||
"contract_count": int(row.get("contract_count") or 0),
|
||||
"into_pool_time": row.get("into_pool_time"),
|
||||
"into_pool_by": map_user(row.get("into_pool_by")),
|
||||
"into_pool_reason": _str_or_none(row.get("into_pool_reason"), 500),
|
||||
"claim_time": row.get("claim_time"),
|
||||
"assign_time": row.get("assign_time"),
|
||||
"assign_by": map_user(row.get("assign_by")),
|
||||
"protect_time": row.get("protect_time"),
|
||||
"business_license_img": resolve_oss_id_csv(row.get("business_license_images"), oss_map) or None,
|
||||
"business_permit_images": resolve_oss_id_csv(row.get("business_permit_images"), oss_map) or None,
|
||||
"other_cert_images": resolve_oss_id_csv(row.get("other_cert_images"), oss_map) or None,
|
||||
"clue_id": legacy_id_to_str(row.get("clue_id")),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_contact(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"contact#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"customer_id": legacy_id_to_str(row.get("customer_id")),
|
||||
"customer_name": _str_or_none(row.get("customer_name"), 200),
|
||||
"contact_name": (row.get("contact_name") or "")[:100],
|
||||
"gender": _str_or_none(row.get("gender"), 10),
|
||||
"mobile": _str_or_none(row.get("mobile"), 20),
|
||||
"telephone": _str_or_none(row.get("telephone"), 30),
|
||||
"email": _str_or_none(row.get("email"), 100),
|
||||
"wechat": _str_or_none(row.get("wechat"), 100),
|
||||
"qq": _str_or_none(row.get("qq"), 20),
|
||||
"department": _str_or_none(row.get("department"), 100),
|
||||
"position": _str_or_none(row.get("position"), 50),
|
||||
"role": _str_or_none(row.get("role"), 20),
|
||||
"birthday": row.get("birthday"),
|
||||
"address": _str_or_none(row.get("address"), 255),
|
||||
"is_key": int(row.get("is_key") or 0),
|
||||
"is_primary": int(row.get("is_primary") or 0),
|
||||
"status": (row.get("status") or "active")[:20],
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_follow(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"follow#{rid}"
|
||||
owner_id = resolve_portal_user_id(row.get("create_by"), crm_user_map, admin_id, stats, label)
|
||||
attachment = resolve_oss_id_csv(row.get("attachments"), oss_map) or None
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"customer_id": legacy_id_to_str(row.get("customer_id")),
|
||||
"customer_name": _str_or_none(row.get("customer_name"), 200),
|
||||
"contact_id": legacy_id_to_str(row.get("contact_id")),
|
||||
"contact_name": _str_or_none(row.get("contact_name"), 100),
|
||||
"follow_type": normalize_follow_type(row.get("follow_type")),
|
||||
"follow_content": row.get("follow_content") or "",
|
||||
"follow_time": row.get("create_time"),
|
||||
"next_time": row.get("next_time"),
|
||||
"attachment": attachment,
|
||||
"owner_user_id": owner_id,
|
||||
"owner_user_name": _str_or_none(row.get("create_by_name"), 50),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": owner_id,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": resolve_portal_user_id(row.get("update_by"), crm_user_map, admin_id, stats, label),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_pool(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"pool#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
create_by = map_user(row.get("create_by"))
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"customer_id": legacy_id_to_str(row.get("customer_id")),
|
||||
"customer_name": _str_or_none(row.get("customer_name"), 200),
|
||||
"operation_type": (row.get("operation_type") or "")[:20],
|
||||
"from_user_id": map_user(row.get("from_user_id")),
|
||||
"from_user_name": _str_or_none(row.get("from_user_name"), 50),
|
||||
"to_user_id": map_user(row.get("to_user_id")),
|
||||
"to_user_name": _str_or_none(row.get("to_user_name"), 50),
|
||||
"reason": _str_or_none(row.get("reason"), 500),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": create_by,
|
||||
"create_by_name": _str_or_none(row.get("create_by_name"), 50),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
INSERT_CUSTOMER_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_customer (
|
||||
id, customer_no, customer_name, short_name, customer_type, mobile, telephone, email, website,
|
||||
level, source, industry, park_area, payment_terms, scale, province, city, district, address,
|
||||
status, deal_status, owner_user_id, owner_user_name, last_time, next_time, follow_count,
|
||||
deal_time, deal_amount, contact_count, opportunity_count, contract_count, into_pool_time,
|
||||
into_pool_by, into_pool_reason, claim_time, assign_time, assign_by, protect_time,
|
||||
business_license_img, business_permit_images, other_cert_images, clue_id,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :customer_no, :customer_name, :short_name, :customer_type, :mobile, :telephone, :email, :website,
|
||||
:level, :source, :industry, :park_area, :payment_terms, :scale, :province, :city, :district, :address,
|
||||
:status, :deal_status, :owner_user_id, :owner_user_name, :last_time, :next_time, :follow_count,
|
||||
:deal_time, :deal_amount, :contact_count, :opportunity_count, :contract_count, :into_pool_time,
|
||||
:into_pool_by, :into_pool_reason, :claim_time, :assign_time, :assign_by, :protect_time,
|
||||
:business_license_img, :business_permit_images, :other_cert_images, :clue_id,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_CONTACT_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_contact (
|
||||
id, customer_id, customer_name, contact_name, gender, mobile, telephone, email, wechat, qq,
|
||||
department, position, role, birthday, address, is_key, is_primary, status,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :customer_id, :customer_name, :contact_name, :gender, :mobile, :telephone, :email, :wechat, :qq,
|
||||
:department, :position, :role, :birthday, :address, :is_key, :is_primary, :status,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_FOLLOW_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_customer_follow (
|
||||
id, customer_id, customer_name, contact_id, contact_name, follow_type, follow_content,
|
||||
follow_time, next_time, attachment, owner_user_id, owner_user_name,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :customer_id, :customer_name, :contact_id, :contact_name, :follow_type, :follow_content,
|
||||
:follow_time, :next_time, :attachment, :owner_user_id, :owner_user_name,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_POOL_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_customer_pool (
|
||||
id, customer_id, customer_name, operation_type, from_user_id, from_user_name, to_user_id, to_user_name,
|
||||
reason, tenant_no, order_num, remark, create_by, create_by_name, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :customer_id, :customer_name, :operation_type, :from_user_id, :from_user_name, :to_user_id, :to_user_name,
|
||||
:reason, :tenant_no, :order_num, :remark, :create_by, :create_by_name, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
STEP_INSERT_SQL = {
|
||||
"crm_customer": INSERT_CUSTOMER_SQL,
|
||||
"crm_contact": INSERT_CONTACT_SQL,
|
||||
"crm_customer_follow": INSERT_FOLLOW_SQL,
|
||||
"crm_customer_pool": INSERT_POOL_SQL,
|
||||
}
|
||||
|
||||
|
||||
def write_mapping_csv(table_key: str, rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
|
||||
w.writeheader()
|
||||
for row in rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
|
||||
|
||||
|
||||
def migrate_table(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
table: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
) -> List[dict]:
|
||||
step = stats.step(table)
|
||||
source_rows = fetch_source_rows(source_engine, table, include_deleted)
|
||||
step.source_count = len(source_rows)
|
||||
|
||||
transformers = {
|
||||
"crm_customer": lambda r: transform_customer(r, tenant_no, crm_user_map, admin_id, oss_map, stats),
|
||||
"crm_contact": lambda r: transform_contact(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_customer_follow": lambda r: transform_follow(r, tenant_no, crm_user_map, admin_id, oss_map, stats),
|
||||
"crm_customer_pool": lambda r: transform_pool(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
}
|
||||
payloads = [transformers[table](r) for r in source_rows]
|
||||
|
||||
if dry_run:
|
||||
step.inserted = len(payloads)
|
||||
return source_rows
|
||||
|
||||
insert_sql = STEP_INSERT_SQL[table]
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, table, rid):
|
||||
if update_existing:
|
||||
# 简化:仅支持首次灌库;更新逻辑后续按需补充
|
||||
step.skipped += 1
|
||||
else:
|
||||
step.skipped += 1
|
||||
else:
|
||||
conn.execute(insert_sql, payload)
|
||||
step.inserted += 1
|
||||
return source_rows
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 客户四表迁移 [{mode}] ===")
|
||||
for table, _ in MIGRATION_STEPS:
|
||||
s = stats.step(table)
|
||||
print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:20]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_dir: Path,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
only_tables: Optional[List[str]],
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
oss_map = load_oss_id_to_url(target_engine, tenant_no)
|
||||
print(f"sys_oss 映射条数: {len(oss_map)}")
|
||||
|
||||
selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
|
||||
for table in selected:
|
||||
print(f"\n>> 迁移 {table} ...")
|
||||
source_rows = migrate_table(
|
||||
source_engine,
|
||||
target_engine,
|
||||
table,
|
||||
tenant_no=tenant_no,
|
||||
crm_user_map=crm_user_map,
|
||||
admin_id=admin_id,
|
||||
oss_map=oss_map,
|
||||
stats=stats,
|
||||
dry_run=dry_run,
|
||||
update_existing=update_existing,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
if table == "crm_customer" and not dry_run:
|
||||
write_mapping_csv("customer", source_rows, mapping_dir / "crm_customer_id_mapping.csv")
|
||||
|
||||
print_report(stats, dry_run)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM customer bundle from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true")
|
||||
parser.add_argument("--include-deleted", action="store_true")
|
||||
parser.add_argument("--tables", default="", help="逗号分隔,默认全部四表")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
include_deleted=args.include_deleted,
|
||||
only_tables=only_tables,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,606 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 商机相关表迁入 shanxia2.0。
|
||||
|
||||
顺序:crm_opportunity → crm_opportunity_follow → crm_opportunity_product → crm_opportunity_stage_log
|
||||
用户外键经 user_mapping 转换;附件 OSS ID → 目标 sys_oss 相对 URL;业务 ID 原样 str(id)。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_opportunity.py --dry-run --env-file .env.production
|
||||
venv/bin/python scripts/migrate_crm_opportunity.py --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
if str(MIGRATION_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MIGRATION_DIR))
|
||||
|
||||
from minio_url import resolve_oss_id_csv # noqa: E402
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
FOLLOW_TYPE_MAP = {
|
||||
"电话": "phone",
|
||||
"phone": "phone",
|
||||
"微信": "wechat",
|
||||
"wechat": "wechat",
|
||||
"邮件": "email",
|
||||
"email": "email",
|
||||
"拜访": "visit",
|
||||
"visit": "visit",
|
||||
"会议": "meeting",
|
||||
"meeting": "meeting",
|
||||
"其他": "other",
|
||||
"other": "other",
|
||||
}
|
||||
|
||||
MIGRATION_STEPS = (
|
||||
("crm_opportunity", "opportunity"),
|
||||
("crm_opportunity_follow", "opportunity_follow"),
|
||||
("crm_opportunity_product", "opportunity_product"),
|
||||
("crm_opportunity_stage_log", "opportunity_stage_log"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
steps: Dict[str, StepStats] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str) -> StepStats:
|
||||
if name not in self.steps:
|
||||
self.steps[name] = StepStats()
|
||||
return self.steps[name]
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
|
||||
return mapped
|
||||
|
||||
|
||||
def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
|
||||
with target_engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
|
||||
{"t": tenant_no},
|
||||
).mappings().all()
|
||||
return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
|
||||
|
||||
|
||||
def normalize_follow_type(raw: Any) -> str:
|
||||
value = (str(raw).strip() if raw is not None else "") or "other"
|
||||
return FOLLOW_TYPE_MAP.get(value, FOLLOW_TYPE_MAP.get(value.lower(), "other"))
|
||||
|
||||
|
||||
def _dec(value: Any, default: Optional[Decimal] = None) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s[:max_len] if max_len else s
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def target_row_exists(conn, table: str, row_id: str) -> bool:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
|
||||
|
||||
|
||||
def transform_opportunity(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"opportunity#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"opportunity_no": (row.get("opportunity_no") or "")[:50],
|
||||
"opportunity_name": (row.get("opportunity_name") or "")[:200],
|
||||
"customer_id": legacy_id_to_str(row.get("customer_id")) or "",
|
||||
"customer_name": _str_or_none(row.get("customer_name"), 200),
|
||||
"contact_id": legacy_id_to_str(row.get("contact_id")),
|
||||
"contact_name": _str_or_none(row.get("contact_name"), 50),
|
||||
"stage": (row.get("stage") or "initial")[:20],
|
||||
"stage_percent": int(row.get("stage_percent") or 10),
|
||||
"status": (row.get("status") or "following")[:20],
|
||||
"win_rate": _dec(row.get("win_rate"), Decimal("50.00")),
|
||||
"amount": _dec(row.get("amount"), Decimal("0.00")),
|
||||
"expect_deal_date": row.get("expect_deal_date"),
|
||||
"actual_deal_date": row.get("actual_deal_date"),
|
||||
"lost_reason": _str_or_none(row.get("lost_reason"), 500),
|
||||
"owner_user_id": map_user(row.get("owner_user_id")),
|
||||
"owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
|
||||
"product_count": int(row.get("product_count") or 0),
|
||||
"last_time": row.get("last_time"),
|
||||
"next_time": row.get("next_time"),
|
||||
"follow_count": int(row.get("follow_count") or 0),
|
||||
"contract_id": legacy_id_to_str(row.get("contract_id")),
|
||||
"source": _str_or_none(row.get("source"), 20),
|
||||
"type": _str_or_none(row.get("type"), 20),
|
||||
"description": row.get("description"),
|
||||
"competitors": _str_or_none(row.get("competitors"), 500),
|
||||
"key_factors": _str_or_none(row.get("key_factors"), 500),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_opportunity_follow(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"opportunity_follow#{rid}"
|
||||
owner_id = resolve_portal_user_id(row.get("create_by"), crm_user_map, admin_id, stats, label)
|
||||
follow_by = resolve_portal_user_id(row.get("follow_by"), crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")) or "",
|
||||
"follow_type": normalize_follow_type(row.get("follow_type")),
|
||||
"follow_content": row.get("follow_content") or "",
|
||||
"follow_time": row.get("follow_time") or row.get("create_time"),
|
||||
"stage": _str_or_none(row.get("stage"), 20),
|
||||
"stage_percent": int(row["stage_percent"]) if row.get("stage_percent") is not None else None,
|
||||
"next_time": row.get("next_time"),
|
||||
"next_content": _str_or_none(row.get("next_content"), 500),
|
||||
"attachments": resolve_oss_id_csv(row.get("attachments"), oss_map) or None,
|
||||
"contact_id": legacy_id_to_str(row.get("contact_id")),
|
||||
"contact_name": _str_or_none(row.get("contact_name"), 50),
|
||||
"location": _str_or_none(row.get("location"), 200),
|
||||
"duration": int(row["duration"]) if row.get("duration") is not None else None,
|
||||
"follow_by": follow_by or owner_id,
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": owner_id,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": resolve_portal_user_id(row.get("update_by"), crm_user_map, admin_id, stats, label),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_opportunity_product(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"opportunity_product#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")) or "",
|
||||
"product_id": legacy_id_to_str(row.get("product_id")) or "",
|
||||
"product_name": _str_or_none(row.get("product_name"), 200),
|
||||
"product_no": _str_or_none(row.get("product_no"), 50),
|
||||
"category_id": legacy_id_to_str(row.get("category_id")),
|
||||
"category_name": _str_or_none(row.get("category_name"), 100),
|
||||
"model": _str_or_none(row.get("model"), 100),
|
||||
"unit": (row.get("unit") or "件")[:20],
|
||||
"quantity": _dec(row.get("quantity"), Decimal("0")),
|
||||
"price": _dec(row.get("price"), Decimal("0.00")),
|
||||
"discount": _dec(row.get("discount"), Decimal("0.00")),
|
||||
"subtotal": _dec(row.get("subtotal"), Decimal("0.00")),
|
||||
"sort": int(row.get("sort") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_opportunity_stage_log(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"opportunity_stage_log#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")) or "",
|
||||
"from_stage": _str_or_none(row.get("from_stage"), 20),
|
||||
"from_stage_name": _str_or_none(row.get("from_stage_name"), 50),
|
||||
"from_percent": int(row["from_percent"]) if row.get("from_percent") is not None else None,
|
||||
"to_stage": (row.get("to_stage") or "")[:20],
|
||||
"to_stage_name": _str_or_none(row.get("to_stage_name"), 50),
|
||||
"to_percent": int(row["to_percent"]) if row.get("to_percent") is not None else None,
|
||||
"change_type": (row.get("change_type") or "forward")[:20],
|
||||
"change_reason": _str_or_none(row.get("change_reason"), 500),
|
||||
"change_amount": _dec(row.get("change_amount")),
|
||||
"lost_detail": _str_or_none(row.get("lost_detail"), 2000),
|
||||
"competitor_info": _str_or_none(row.get("competitor_info"), 1000),
|
||||
"improvement": _str_or_none(row.get("improvement"), 1000),
|
||||
"won_remark": _str_or_none(row.get("won_remark"), 2000),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
INSERT_OPPORTUNITY_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_opportunity (
|
||||
id, opportunity_no, opportunity_name, customer_id, customer_name, contact_id, contact_name,
|
||||
stage, stage_percent, status, win_rate, amount, expect_deal_date, actual_deal_date, lost_reason,
|
||||
owner_user_id, owner_user_name, product_count, last_time, next_time, follow_count, contract_id,
|
||||
source, type, description, competitors, key_factors,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :opportunity_no, :opportunity_name, :customer_id, :customer_name, :contact_id, :contact_name,
|
||||
:stage, :stage_percent, :status, :win_rate, :amount, :expect_deal_date, :actual_deal_date, :lost_reason,
|
||||
:owner_user_id, :owner_user_name, :product_count, :last_time, :next_time, :follow_count, :contract_id,
|
||||
:source, :type, :description, :competitors, :key_factors,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_OPPORTUNITY_FOLLOW_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_opportunity_follow (
|
||||
id, opportunity_id, follow_type, follow_content, follow_time, stage, stage_percent,
|
||||
next_time, next_content, attachments, contact_id, contact_name, location, duration, follow_by,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :opportunity_id, :follow_type, :follow_content, :follow_time, :stage, :stage_percent,
|
||||
:next_time, :next_content, :attachments, :contact_id, :contact_name, :location, :duration, :follow_by,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_OPPORTUNITY_PRODUCT_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_opportunity_product (
|
||||
id, opportunity_id, product_id, product_name, product_no, category_id, category_name,
|
||||
model, unit, quantity, price, discount, subtotal, sort,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :opportunity_id, :product_id, :product_name, :product_no, :category_id, :category_name,
|
||||
:model, :unit, :quantity, :price, :discount, :subtotal, :sort,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_OPPORTUNITY_STAGE_LOG_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_opportunity_stage_log (
|
||||
id, opportunity_id, from_stage, from_stage_name, from_percent, to_stage, to_stage_name, to_percent,
|
||||
change_type, change_reason, change_amount, lost_detail, competitor_info, improvement, won_remark,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :opportunity_id, :from_stage, :from_stage_name, :from_percent, :to_stage, :to_stage_name, :to_percent,
|
||||
:change_type, :change_reason, :change_amount, :lost_detail, :competitor_info, :improvement, :won_remark,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
STEP_INSERT_SQL = {
|
||||
"crm_opportunity": INSERT_OPPORTUNITY_SQL,
|
||||
"crm_opportunity_follow": INSERT_OPPORTUNITY_FOLLOW_SQL,
|
||||
"crm_opportunity_product": INSERT_OPPORTUNITY_PRODUCT_SQL,
|
||||
"crm_opportunity_stage_log": INSERT_OPPORTUNITY_STAGE_LOG_SQL,
|
||||
}
|
||||
|
||||
|
||||
def write_mapping_csv(source_rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
|
||||
w.writeheader()
|
||||
for row in source_rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
|
||||
|
||||
|
||||
def migrate_table(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
table: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
) -> List[dict]:
|
||||
step = stats.step(table)
|
||||
source_rows = fetch_source_rows(source_engine, table, include_deleted)
|
||||
step.source_count = len(source_rows)
|
||||
|
||||
transformers = {
|
||||
"crm_opportunity": lambda r: transform_opportunity(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_opportunity_follow": lambda r: transform_opportunity_follow(
|
||||
r, tenant_no, crm_user_map, admin_id, oss_map, stats
|
||||
),
|
||||
"crm_opportunity_product": lambda r: transform_opportunity_product(
|
||||
r, tenant_no, crm_user_map, admin_id, stats
|
||||
),
|
||||
"crm_opportunity_stage_log": lambda r: transform_opportunity_stage_log(
|
||||
r, tenant_no, crm_user_map, admin_id, stats
|
||||
),
|
||||
}
|
||||
payloads = [transformers[table](r) for r in source_rows]
|
||||
|
||||
if dry_run:
|
||||
step.inserted = len(payloads)
|
||||
return source_rows
|
||||
|
||||
insert_sql = STEP_INSERT_SQL[table]
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, table, rid):
|
||||
if update_existing:
|
||||
step.skipped += 1
|
||||
else:
|
||||
step.skipped += 1
|
||||
else:
|
||||
conn.execute(insert_sql, payload)
|
||||
step.inserted += 1
|
||||
return source_rows
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 商机四表迁移 [{mode}] ===")
|
||||
for table, _ in MIGRATION_STEPS:
|
||||
s = stats.step(table)
|
||||
print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:20]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_dir: Path,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
only_tables: Optional[List[str]],
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
oss_map = load_oss_id_to_url(target_engine, tenant_no)
|
||||
print(f"sys_oss 映射条数: {len(oss_map)}")
|
||||
|
||||
selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
|
||||
for table in selected:
|
||||
print(f"\n>> 迁移 {table} ...")
|
||||
source_rows = migrate_table(
|
||||
source_engine,
|
||||
target_engine,
|
||||
table,
|
||||
tenant_no=tenant_no,
|
||||
crm_user_map=crm_user_map,
|
||||
admin_id=admin_id,
|
||||
oss_map=oss_map,
|
||||
stats=stats,
|
||||
dry_run=dry_run,
|
||||
update_existing=update_existing,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
if table == "crm_opportunity" and not dry_run:
|
||||
write_mapping_csv(source_rows, mapping_dir / "crm_opportunity_id_mapping.csv")
|
||||
|
||||
print_report(stats, dry_run)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM opportunity tables from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true")
|
||||
parser.add_argument("--include-deleted", action="store_true")
|
||||
parser.add_argument("--tables", default="", help="逗号分隔,默认全部四表")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
include_deleted=args.include_deleted,
|
||||
only_tables=only_tables,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,393 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 回款/开票相关表迁入 shanxia2.0。
|
||||
|
||||
门户侧当前仅有 crm_invoice_info 表;源库回款/发票主表及明细均为空,跳过。
|
||||
实际迁移:crm_invoice_info(客户开票信息子表)。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_payment_invoice.py --dry-run --env-file .env.production
|
||||
venv/bin/python scripts/migrate_crm_payment_invoice.py --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
# 源库存在但门户暂无对应业务表,或源库无数据
|
||||
SKIPPED_TABLES = (
|
||||
"crm_payment",
|
||||
"crm_payment_plan",
|
||||
"crm_payment_collection",
|
||||
"crm_invoice",
|
||||
"crm_invoice_item",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
steps: Dict[str, StepStats] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str) -> StepStats:
|
||||
if name not in self.steps:
|
||||
self.steps[name] = StepStats()
|
||||
return self.steps[name]
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
|
||||
return mapped
|
||||
|
||||
|
||||
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s[:max_len] if max_len else s
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def count_source_rows(engine: Engine, table: str) -> int:
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE del_flag=0")).scalar_one())
|
||||
except Exception:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}`")).scalar_one())
|
||||
|
||||
|
||||
def target_table_exists(engine: Engine, table: str) -> bool:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(text("SHOW TABLES LIKE :t"), {"t": table}).first()
|
||||
return row is not None
|
||||
|
||||
|
||||
def target_row_exists(conn, table: str, row_id: str) -> bool:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
|
||||
|
||||
|
||||
def transform_invoice_info(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> Optional[dict]:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"invoice_info#{rid}"
|
||||
customer_id = legacy_id_to_str(row.get("customer_id"))
|
||||
if not customer_id:
|
||||
stats.warnings.append(f"{label}: 缺少 customer_id,已跳过")
|
||||
return None
|
||||
|
||||
company_name = _str_or_none(row.get("company_name"), 200)
|
||||
if not company_name:
|
||||
stats.warnings.append(f"{label}: company_name 为空,已跳过")
|
||||
return None
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
is_default = (str(row.get("is_default") or "0").strip() or "0")[:1]
|
||||
if is_default not in ("0", "1"):
|
||||
is_default = "1" if is_default in ("true", "True", "Y", "y") else "0"
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"customer_id": customer_id,
|
||||
"company_name": company_name,
|
||||
"tax_no": None,
|
||||
"company_address": _str_or_none(row.get("company_address"), 500),
|
||||
"legal_representative": _str_or_none(row.get("legal_representative"), 100),
|
||||
"agent": _str_or_none(row.get("agent"), 100),
|
||||
"phone_fax": _str_or_none(row.get("phone_fax"), 100),
|
||||
"bank_name": _str_or_none(row.get("bank_name"), 200),
|
||||
"bank_account": _str_or_none(row.get("bank_account"), 100),
|
||||
"is_default": is_default,
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 512),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
INSERT_INVOICE_INFO_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_invoice_info (
|
||||
id, customer_id, company_name, tax_no, company_address, legal_representative,
|
||||
agent, phone_fax, bank_name, bank_account, is_default,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :customer_id, :company_name, :tax_no, :company_address, :legal_representative,
|
||||
:agent, :phone_fax, :bank_name, :bank_account, :is_default,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def write_mapping_csv(rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
|
||||
w.writeheader()
|
||||
for row in rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
|
||||
|
||||
|
||||
def report_skipped_tables(source_engine: Engine, target_engine: Engine, stats: MigrationStats) -> None:
|
||||
print("\n>> 跳过表(门户无表或源库无数据):")
|
||||
for table in SKIPPED_TABLES:
|
||||
source_count = count_source_rows(source_engine, table)
|
||||
target_exists = target_table_exists(target_engine, table)
|
||||
step = stats.step(table)
|
||||
step.source_count = source_count
|
||||
if source_count == 0:
|
||||
print(f" {table}: 源库 0 行,跳过")
|
||||
elif not target_exists:
|
||||
print(f" {table}: 源库 {source_count} 行,门户无对应表,跳过")
|
||||
stats.warnings.append(f"{table}: 源库有 {source_count} 行但门户无表,未迁移")
|
||||
else:
|
||||
print(f" {table}: 源={source_count} 目标表存在(本脚本未实现,需单独处理)")
|
||||
|
||||
|
||||
def migrate_invoice_info(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
dry_run: bool,
|
||||
mapping_dir: Path,
|
||||
) -> None:
|
||||
table = "crm_invoice_info"
|
||||
step = stats.step(table)
|
||||
source_rows = fetch_source_rows(source_engine, table, include_deleted=False)
|
||||
step.source_count = len(source_rows)
|
||||
|
||||
payloads: List[dict] = []
|
||||
for row in source_rows:
|
||||
payload = transform_invoice_info(row, tenant_no, crm_user_map, admin_id, stats)
|
||||
if payload:
|
||||
payloads.append(payload)
|
||||
else:
|
||||
step.skipped += 1
|
||||
|
||||
if dry_run:
|
||||
step.inserted = len(payloads)
|
||||
print(f"{table}: 源={step.source_count} 可插入={step.inserted} 跳过={step.skipped}")
|
||||
return
|
||||
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, table, rid):
|
||||
step.skipped += 1
|
||||
else:
|
||||
conn.execute(INSERT_INVOICE_INFO_SQL, payload)
|
||||
step.inserted += 1
|
||||
|
||||
write_mapping_csv(source_rows, mapping_dir / "crm_invoice_info_id_mapping.csv")
|
||||
print(f"{table}: 源={step.source_count} 插入={step.inserted} 跳过={step.skipped}")
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 回款/开票迁移 [{mode}] ===")
|
||||
for name in (*SKIPPED_TABLES, "crm_invoice_info"):
|
||||
s = stats.step(name)
|
||||
if s.source_count or s.inserted or s.skipped:
|
||||
print(f"{name}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:20]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_dir: Path,
|
||||
dry_run: bool,
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
|
||||
report_skipped_tables(source_engine, target_engine, stats)
|
||||
print("\n>> 迁移 crm_invoice_info ...")
|
||||
migrate_invoice_info(
|
||||
source_engine,
|
||||
target_engine,
|
||||
tenant_no=tenant_no,
|
||||
crm_user_map=crm_user_map,
|
||||
admin_id=admin_id,
|
||||
stats=stats,
|
||||
dry_run=dry_run,
|
||||
mapping_dir=mapping_dir,
|
||||
)
|
||||
print_report(stats, dry_run)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM payment/invoice bundle from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,372 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 产品主表迁入 shanxia2.0 crm_product。
|
||||
|
||||
数据源:仅 sx 库 crm_product(原 CRM 系统),不含地磅 cargos、报销 bx_*。
|
||||
策略见 docs/data-migration/05-id-preservation-policy.md:id/category_id 原样 str(bigint)。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_product.py --dry-run --env-file .env.production
|
||||
|
||||
venv/bin/python scripts/migrate_crm_product.py --env-file .env.production \\
|
||||
--user-mapping ../docs/data-migration/mappings/user_mapping_result.csv \\
|
||||
--mapping-out ../docs/data-migration/mappings/crm_product_id_mapping.csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
SOURCE_TABLE = "crm_product"
|
||||
TARGET_TABLE = "crm_product"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
|
||||
return mapped
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, include_deleted: bool) -> List[dict]:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{SOURCE_TABLE}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def count_target_rows(engine: Engine, tenant_no: str) -> int:
|
||||
with engine.connect() as conn:
|
||||
return int(
|
||||
conn.execute(
|
||||
text(
|
||||
f"SELECT COUNT(*) FROM `{TARGET_TABLE}` "
|
||||
"WHERE tenant_no=:t AND del_flag=0"
|
||||
),
|
||||
{"t": tenant_no},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _dec(value: Any) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def transform_row(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
new_id = legacy_id_to_str(row["id"])
|
||||
cat_id = legacy_id_to_str(row.get("category_id"))
|
||||
|
||||
status = str(row.get("status") or "on").strip()
|
||||
if status not in ("on", "off"):
|
||||
status = "on"
|
||||
|
||||
unit = (row.get("unit") or "").strip() or None
|
||||
|
||||
return {
|
||||
"id": new_id,
|
||||
"product_no": (row.get("product_no") or "")[:50],
|
||||
"product_name": (row.get("product_name") or "")[:200],
|
||||
"category_id": cat_id,
|
||||
"category_name": (row.get("category_name") or "")[:100] or None,
|
||||
"model": (row.get("model") or "")[:100] or None,
|
||||
"unit": (unit[:20] if unit else None),
|
||||
"barcode": (row.get("barcode") or "")[:100] or None,
|
||||
"price": _dec(row.get("price")),
|
||||
"cost_price": _dec(row.get("cost")),
|
||||
"stock": int(row.get("stock") or 0),
|
||||
"low_stock": int(row.get("low_stock") or 10),
|
||||
"tax_rate": _dec(row.get("tax_rate")) or Decimal("13.00"),
|
||||
"is_toxic": 0,
|
||||
"is_explosive": 0,
|
||||
"status": status[:3],
|
||||
"description": row.get("description"),
|
||||
"images": (row.get("images") or "")[:1000] or None,
|
||||
"sale_count": int(row.get("sale_count") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": (row.get("remark") or "")[:512] or None,
|
||||
"create_by": resolve_portal_user_id(
|
||||
row.get("create_by"), crm_user_map, admin_id, stats, f"product#{new_id} create_by"
|
||||
),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": resolve_portal_user_id(
|
||||
row.get("update_by"), crm_user_map, admin_id, stats, f"product#{new_id} update_by"
|
||||
),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
INSERT_SQL = text(
|
||||
f"""
|
||||
INSERT INTO `{TARGET_TABLE}` (
|
||||
id, product_no, product_name, category_id, category_name, model, unit, barcode,
|
||||
price, cost_price, stock, low_stock, tax_rate, is_toxic, is_explosive, status,
|
||||
description, images, sale_count, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :product_no, :product_name, :category_id, :category_name, :model, :unit, :barcode,
|
||||
:price, :cost_price, :stock, :low_stock, :tax_rate, :is_toxic, :is_explosive, :status,
|
||||
:description, :images, :sale_count, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
UPDATE_SQL = text(
|
||||
f"""
|
||||
UPDATE `{TARGET_TABLE}` SET
|
||||
product_no=:product_no, product_name=:product_name, category_id=:category_id,
|
||||
category_name=:category_name, model=:model, unit=:unit, barcode=:barcode,
|
||||
price=:price, cost_price=:cost_price, stock=:stock, low_stock=:low_stock,
|
||||
tax_rate=:tax_rate, status=:status, description=:description, images=:images,
|
||||
sale_count=:sale_count, tenant_no=:tenant_no, order_num=:order_num, remark=:remark,
|
||||
create_by=:create_by, update_by=:update_by, update_time=:update_time, del_flag=:del_flag
|
||||
WHERE id=:id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def target_row_exists(conn, row_id: str) -> bool:
|
||||
return int(
|
||||
conn.execute(text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE id=:id"), {"id": row_id}).scalar_one()
|
||||
) > 0
|
||||
|
||||
|
||||
def write_mapping_csv(rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(
|
||||
f,
|
||||
fieldnames=["source_id", "new_id", "product_no", "product_name", "category_id", "migrated_at"],
|
||||
)
|
||||
w.writeheader()
|
||||
for row in rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow(
|
||||
{
|
||||
"source_id": sid,
|
||||
"new_id": sid,
|
||||
"product_no": row.get("product_no") or "",
|
||||
"product_name": row.get("product_name") or "",
|
||||
"category_id": legacy_id_to_str(row.get("category_id")) or "",
|
||||
"migrated_at": migrated_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 产品迁移 [{mode}] ===")
|
||||
print(f"源表行数: {stats.source_count}")
|
||||
print(f"插入: {stats.inserted} 更新: {stats.updated} 跳过: {stats.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:15]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 15:
|
||||
print(f" ... 另有 {len(stats.warnings) - 15} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_out: Optional[Path],
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
|
||||
source_rows = fetch_source_rows(source_engine, include_deleted)
|
||||
stats.source_count = len(source_rows)
|
||||
payloads = [transform_row(r, tenant_no, crm_user_map, admin_id, stats) for r in source_rows]
|
||||
|
||||
if dry_run:
|
||||
print("\n预览(前 5 条):")
|
||||
for p in payloads[:5]:
|
||||
print(f" id={p['id']} no={p['product_no']} name={p['product_name']!r} cat={p['category_id']}")
|
||||
stats.inserted = len(payloads)
|
||||
print_report(stats, dry_run=True)
|
||||
return stats
|
||||
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, rid):
|
||||
if update_existing:
|
||||
conn.execute(UPDATE_SQL, payload)
|
||||
stats.updated += 1
|
||||
else:
|
||||
stats.skipped += 1
|
||||
else:
|
||||
conn.execute(INSERT_SQL, payload)
|
||||
stats.inserted += 1
|
||||
|
||||
if mapping_out:
|
||||
write_mapping_csv(source_rows, mapping_out)
|
||||
print(f"\nID 映射已写入: {mapping_out}")
|
||||
|
||||
print_report(stats, dry_run=False)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM products from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true")
|
||||
parser.add_argument("--include-deleted", action="store_true")
|
||||
parser.add_argument("--mapping-out", default="")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
print("数据源: sx.crm_product(原 CRM,不含地磅 cargos)")
|
||||
print(f"目标表现有产品: {count_target_rows(create_engine(target_url), args.tenant_no)}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_out=Path(args.mapping_out) if args.mapping_out else None,
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
include_deleted=args.include_deleted,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,457 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 产品分类迁入 shanxia2.0 crm_product_category。
|
||||
|
||||
策略(2026-07-12 确认,全局见 docs/data-migration/05-id-preservation-policy.md):
|
||||
- 主键 **原样保留**:源 bigint id → 目标 VARCHAR 同值字符串(如 1975766404158746625)
|
||||
- parent_id 同理:0 → '0',非 0 → str(parent_id)
|
||||
- 便于后续 crm_product、报价/合同行项目等直接引用 category_id,无需二次查 mapping
|
||||
- create_by/update_by 经 user_mapping_result.csv 的 crm_user_id 转换;源 1 → 门户 admin
|
||||
|
||||
用法:
|
||||
cd backend && source venv/bin/activate
|
||||
ENV_FILE=.env.production python scripts/migrate_crm_product_category.py --dry-run
|
||||
|
||||
ENV_FILE=.env.production python scripts/migrate_crm_product_category.py \\
|
||||
--source-url mysql+pymysql://root:***@host:3306/sx \\
|
||||
--user-mapping ../docs/data-migration/mappings/user_mapping_result.csv \\
|
||||
--mapping-out ../docs/data-migration/mappings/crm_product_category_id_mapping.csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
SOURCE_TABLE = "crm_product_category"
|
||||
TARGET_TABLE = "crm_product_category"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> str:
|
||||
"""CRM bigint 主键原样保留为字符串(对齐地磅 migrate_dibang_data 策略)。"""
|
||||
if legacy_id is None:
|
||||
return "0"
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
parsed = urlparse(target_url)
|
||||
return urlunparse(parsed._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
"""crm_user_id -> new_user_id;并解析门户 admin id(用于源 create_by=1)。"""
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"用户映射文件不存在: {path}")
|
||||
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if not crm_raw or not new_id:
|
||||
continue
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
"""映射表无 admin 时,从目标库 sys_user 查询。"""
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id FROM sys_user WHERE user_no = 'admin' AND del_flag = 0 LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
if admin_id:
|
||||
return admin_id
|
||||
stats.warnings.append(f"{label}: 源用户 id=1 但门户 admin 未在映射表中找到")
|
||||
return None
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射,置 NULL")
|
||||
return mapped
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, include_deleted: bool) -> List[dict]:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{SOURCE_TABLE}` {where} ORDER BY sort ASC, id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def count_target_rows(engine: Engine, tenant_no: str) -> int:
|
||||
with engine.connect() as conn:
|
||||
return int(
|
||||
conn.execute(
|
||||
text(
|
||||
f"SELECT COUNT(*) FROM `{TARGET_TABLE}` "
|
||||
"WHERE tenant_no = :tenant AND del_flag = 0"
|
||||
),
|
||||
{"tenant": tenant_no},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def topo_sort_categories(rows: List[dict]) -> List[dict]:
|
||||
"""父节点先于子节点;仅顶级时保持 sort 顺序。"""
|
||||
by_id = {int(r["id"]): r for r in rows}
|
||||
children: Dict[int, List[int]] = defaultdict(list)
|
||||
roots: List[int] = []
|
||||
for row in rows:
|
||||
pid = int(row.get("parent_id") or 0)
|
||||
cid = int(row["id"])
|
||||
if pid == 0 or pid not in by_id:
|
||||
roots.append(cid)
|
||||
else:
|
||||
children[pid].append(cid)
|
||||
|
||||
roots.sort(key=lambda i: (int(by_id[i].get("sort") or 0), i))
|
||||
for pid in children:
|
||||
children[pid].sort(key=lambda i: (int(by_id[i].get("sort") or 0), i))
|
||||
|
||||
ordered: List[dict] = []
|
||||
seen: Set[int] = set()
|
||||
queue: deque[int] = deque(roots)
|
||||
while queue:
|
||||
cid = queue.popleft()
|
||||
if cid in seen:
|
||||
continue
|
||||
seen.add(cid)
|
||||
ordered.append(by_id[cid])
|
||||
queue.extend(children.get(cid, []))
|
||||
|
||||
if len(ordered) != len(rows):
|
||||
missing = set(by_id) - seen
|
||||
raise RuntimeError(f"分类树存在环或孤儿节点: {missing}")
|
||||
return ordered
|
||||
|
||||
|
||||
def compute_level(parent_id_str: str, level_cache: Dict[str, int]) -> int:
|
||||
if parent_id_str == "0":
|
||||
return 1
|
||||
parent_level = level_cache.get(parent_id_str, 0)
|
||||
return parent_level + 1 if parent_level else 1
|
||||
|
||||
|
||||
def transform_row(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
level_cache: Dict[str, int],
|
||||
) -> dict:
|
||||
new_id = legacy_id_to_str(row["id"])
|
||||
parent_id_str = legacy_id_to_str(row.get("parent_id") or 0)
|
||||
if int(row.get("parent_id") or 0) == 0:
|
||||
parent_id_str = "0"
|
||||
|
||||
level = compute_level(parent_id_str, level_cache)
|
||||
level_cache[new_id] = level
|
||||
|
||||
create_by = resolve_portal_user_id(
|
||||
row.get("create_by"), crm_user_map, admin_id, stats, f"category#{new_id} create_by"
|
||||
)
|
||||
update_by = resolve_portal_user_id(
|
||||
row.get("update_by"), crm_user_map, admin_id, stats, f"category#{new_id} update_by"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": new_id,
|
||||
"parent_id": parent_id_str,
|
||||
"category_name": (row.get("category_name") or "")[:128],
|
||||
"category_code": None,
|
||||
"level": level,
|
||||
"status": str(row.get("status") if row.get("status") is not None else "0").strip()[:1] or "0",
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": int(row.get("sort") or 0),
|
||||
"remark": (row.get("remark") or "")[:512] or None,
|
||||
"create_by": create_by,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": update_by,
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def target_row_exists(conn, row_id: str) -> bool:
|
||||
n = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE id = :id"),
|
||||
{"id": row_id},
|
||||
).scalar_one()
|
||||
return int(n) > 0
|
||||
|
||||
|
||||
INSERT_SQL = text(
|
||||
f"""
|
||||
INSERT INTO `{TARGET_TABLE}` (
|
||||
id, parent_id, category_name, category_code, level, status,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id,
|
||||
create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :parent_id, :category_name, :category_code, :level, :status,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id,
|
||||
:create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
UPDATE_SQL = text(
|
||||
f"""
|
||||
UPDATE `{TARGET_TABLE}` SET
|
||||
parent_id = :parent_id,
|
||||
category_name = :category_name,
|
||||
level = :level,
|
||||
status = :status,
|
||||
tenant_no = :tenant_no,
|
||||
order_num = :order_num,
|
||||
remark = :remark,
|
||||
create_by = :create_by,
|
||||
update_by = :update_by,
|
||||
update_time = :update_time,
|
||||
del_flag = :del_flag
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def write_mapping_csv(rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(
|
||||
f,
|
||||
fieldnames=[
|
||||
"source_id",
|
||||
"source_parent_id",
|
||||
"new_id",
|
||||
"new_parent_id",
|
||||
"category_name",
|
||||
"migrated_at",
|
||||
],
|
||||
)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
spid = legacy_id_to_str(row.get("parent_id") or 0)
|
||||
if int(row.get("parent_id") or 0) == 0:
|
||||
spid = "0"
|
||||
writer.writerow(
|
||||
{
|
||||
"source_id": sid,
|
||||
"source_parent_id": spid,
|
||||
"new_id": sid,
|
||||
"new_parent_id": spid,
|
||||
"category_name": row.get("category_name") or "",
|
||||
"migrated_at": migrated_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 产品分类迁移 [{mode}] ===")
|
||||
print(f"源表有效行数: {stats.source_count}")
|
||||
print(f"插入: {stats.inserted} 更新: {stats.updated} 跳过: {stats.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:20]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_out: Optional[Path],
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
if admin_id:
|
||||
print(f"门户 admin id(目标库): {admin_id}")
|
||||
|
||||
source_rows = fetch_source_rows(source_engine, include_deleted)
|
||||
stats.source_count = len(source_rows)
|
||||
ordered = topo_sort_categories(source_rows)
|
||||
|
||||
level_cache: Dict[str, int] = {"0": 0}
|
||||
payloads: List[dict] = []
|
||||
for row in ordered:
|
||||
payloads.append(
|
||||
transform_row(row, tenant_no, crm_user_map, admin_id, stats, level_cache)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
print("\n预览(前 10 条,id 原样保留):")
|
||||
for p in payloads[:10]:
|
||||
print(
|
||||
f" id={p['id']} parent={p['parent_id']} "
|
||||
f"name={p['category_name']!r} status={p['status']} level={p['level']}"
|
||||
)
|
||||
stats.inserted = len(payloads)
|
||||
print_report(stats, dry_run=True)
|
||||
return stats
|
||||
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, rid):
|
||||
if update_existing:
|
||||
conn.execute(UPDATE_SQL, payload)
|
||||
stats.updated += 1
|
||||
else:
|
||||
stats.skipped += 1
|
||||
else:
|
||||
conn.execute(INSERT_SQL, payload)
|
||||
stats.inserted += 1
|
||||
|
||||
if mapping_out:
|
||||
write_mapping_csv(ordered, mapping_out)
|
||||
print(f"\nID 映射已写入: {mapping_out}(source_id = new_id,恒等映射)")
|
||||
|
||||
print_report(stats, dry_run=False)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM product categories (preserve source IDs)")
|
||||
parser.add_argument("--source-url", default="", help="Source sx DB URL")
|
||||
parser.add_argument("--target-url", default="", help="Target portal DB URL")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
help="user_mapping_result.csv path",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true", help="Update existing rows with same id")
|
||||
parser.add_argument("--include-deleted", action="store_true")
|
||||
parser.add_argument("--mapping-out", default="", help="Write identity id mapping CSV")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = (
|
||||
args.source_url
|
||||
or os.environ.get("SX_SOURCE_DATABASE_URL")
|
||||
or derive_source_url(target_url)
|
||||
)
|
||||
|
||||
user_mapping_path = Path(args.user_mapping)
|
||||
mapping_out = Path(args.mapping_out) if args.mapping_out else None
|
||||
|
||||
print(f"源库: {urlparse(source_url).path or source_url}")
|
||||
print(f"目标库: {urlparse(target_url).path or target_url}")
|
||||
print(f"租户: {args.tenant_no}")
|
||||
print(f"ID 策略: 原样保留(bigint → 同值字符串)")
|
||||
|
||||
source_engine = create_engine(source_url, pool_pre_ping=True)
|
||||
target_engine = create_engine(target_url, pool_pre_ping=True)
|
||||
|
||||
before = count_target_rows(target_engine, args.tenant_no)
|
||||
print(f"目标表现有分类(del_flag=0): {before}")
|
||||
|
||||
run_migration(
|
||||
source_engine,
|
||||
target_engine,
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=user_mapping_path,
|
||||
mapping_out=mapping_out,
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
include_deleted=args.include_deleted,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,703 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM 报价单相关表迁入 shanxia2.0。
|
||||
|
||||
顺序:crm_supplier_info(依赖)→ crm_quote → crm_quote_product → crm_quote_approval → crm_quote_share_log
|
||||
用户外键经 user_mapping;附件/pdf OSS ID 或绝对 URL → 相对路径。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_quote.py --dry-run --env-file .env.production
|
||||
venv/bin/python scripts/migrate_crm_quote.py --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
if str(MIGRATION_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MIGRATION_DIR))
|
||||
|
||||
from minio_url import extract_minio_object_key, join_relative_urls, resolve_oss_id_csv # noqa: E402
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
MIGRATION_STEPS = (
|
||||
("crm_supplier_info", "supplier_info"),
|
||||
("crm_quote", "quote"),
|
||||
("crm_quote_product", "quote_product"),
|
||||
("crm_quote_approval", "quote_approval"),
|
||||
("crm_quote_share_log", "quote_share_log"),
|
||||
)
|
||||
|
||||
TABLES_WITHOUT_DEL_FLAG = frozenset({"crm_quote_share_log"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
steps: Dict[str, StepStats] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
def step(self, name: str) -> StepStats:
|
||||
if name not in self.steps:
|
||||
self.steps[name] = StepStats()
|
||||
return self.steps[name]
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def legacy_parent_id(raw: Any) -> str:
|
||||
if raw is None:
|
||||
return "0"
|
||||
try:
|
||||
val = int(raw)
|
||||
return "0" if val == 0 else str(val)
|
||||
except (TypeError, ValueError):
|
||||
s = str(raw).strip()
|
||||
return s if s else "0"
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return admin_id if required else None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return admin_id if required else None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
|
||||
return admin_id if required else None
|
||||
return mapped
|
||||
|
||||
|
||||
def load_oss_id_to_url(target_engine: Engine, tenant_no: str) -> Dict[str, str]:
|
||||
with target_engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
text("SELECT id, url FROM sys_oss WHERE tenant_no=:t AND del_flag=0"),
|
||||
{"t": tenant_no},
|
||||
).mappings().all()
|
||||
return {str(r["id"]): (r["url"] or "").strip() for r in rows if r["id"]}
|
||||
|
||||
|
||||
def normalize_attachment_csv(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
oss_resolved = resolve_oss_id_csv(raw, oss_map)
|
||||
if oss_resolved:
|
||||
return oss_resolved or None
|
||||
parts: List[str] = []
|
||||
for token in str(raw).split(","):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
if token.isdigit() and token in oss_map:
|
||||
parts.append(oss_map[token])
|
||||
else:
|
||||
key = extract_minio_object_key(token)
|
||||
if key:
|
||||
parts.append(key)
|
||||
joined = join_relative_urls(parts)
|
||||
return joined or None
|
||||
|
||||
|
||||
def normalize_pdf_url(raw: Any, oss_map: Dict[str, str]) -> Optional[str]:
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
value = str(raw).strip()
|
||||
if value.isdigit() and value in oss_map:
|
||||
return oss_map[value]
|
||||
key = extract_minio_object_key(value)
|
||||
return key or None
|
||||
|
||||
|
||||
def _dec(value: Any, default: Optional[Decimal] = None) -> Optional[Decimal]:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _str_or_none(value: Any, max_len: int = 0) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s[:max_len] if max_len else s
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine, table: str, include_deleted: bool) -> List[dict]:
|
||||
if table in TABLES_WITHOUT_DEL_FLAG:
|
||||
where = ""
|
||||
else:
|
||||
where = "" if include_deleted else "WHERE del_flag = 0"
|
||||
sql = f"SELECT * FROM `{table}` {where} ORDER BY id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def target_row_exists(conn, table: str, row_id: str) -> bool:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}` WHERE id=:id"), {"id": row_id}).scalar_one()) > 0
|
||||
|
||||
|
||||
def transform_supplier_info(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"supplier_info#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"supplier_name": (row.get("supplier_name") or "")[:200],
|
||||
"short_name": _str_or_none(row.get("short_name"), 100),
|
||||
"supplier_code": _str_or_none(row.get("supplier_code"), 50),
|
||||
"supplier_address": _str_or_none(row.get("supplier_address"), 500),
|
||||
"legal_person": _str_or_none(row.get("legal_person"), 100),
|
||||
"agent": _str_or_none(row.get("agent"), 100),
|
||||
"phone_fax": _str_or_none(row.get("phone_fax"), 100),
|
||||
"bank_name": _str_or_none(row.get("bank_name"), 200),
|
||||
"bank_account": _str_or_none(row.get("bank_account"), 100),
|
||||
"status": (row.get("status") or "0")[:1],
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_quote(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"quote#{rid}"
|
||||
|
||||
def map_user(field_val: Any, *, required: bool = False) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label, required=required)
|
||||
|
||||
owner_id = map_user(row.get("owner_user_id"), required=True)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"quote_no": (row.get("quote_no") or "")[:50],
|
||||
"quote_title": (row.get("quote_title") or "")[:200],
|
||||
"quote_type": (row.get("quote_type") or "sales")[:20],
|
||||
"customer_id": legacy_id_to_str(row.get("customer_id")) or "",
|
||||
"customer_name": _str_or_none(row.get("customer_name"), 200),
|
||||
"opportunity_id": legacy_id_to_str(row.get("opportunity_id")),
|
||||
"contact_id": legacy_id_to_str(row.get("contact_id")),
|
||||
"contact_name": _str_or_none(row.get("contact_name"), 50),
|
||||
"contact_mobile": _str_or_none(row.get("contact_mobile"), 20),
|
||||
"contact_email": _str_or_none(row.get("contact_email"), 100),
|
||||
"version": (row.get("version") or "V1.0")[:20],
|
||||
"parent_id": legacy_parent_id(row.get("parent_id")),
|
||||
"is_latest": int(row.get("is_latest") if row.get("is_latest") is not None else 1),
|
||||
"total_amount": _dec(row.get("total_amount"), Decimal("0.00")),
|
||||
"discount_rate": _dec(row.get("discount_rate"), Decimal("0.00")),
|
||||
"discount_amount": _dec(row.get("discount_amount"), Decimal("0.00")),
|
||||
"final_amount": _dec(row.get("final_amount"), Decimal("0.00")),
|
||||
"validity_days": int(row.get("validity_days") or 30),
|
||||
"quote_date": row.get("quote_date"),
|
||||
"expire_date": row.get("expire_date"),
|
||||
"status": (row.get("status") or "draft")[:20],
|
||||
"reject_reason": _str_or_none(row.get("reject_reason"), 500),
|
||||
"pdf_url": normalize_pdf_url(row.get("pdf_url"), oss_map),
|
||||
"pdf_generated": int(row.get("pdf_generated") or 0),
|
||||
"share_link": _str_or_none(row.get("share_link"), 500),
|
||||
"share_code": _str_or_none(row.get("share_code"), 50),
|
||||
"share_password": _str_or_none(row.get("share_password"), 100),
|
||||
"share_expire_time": row.get("share_expire_time"),
|
||||
"view_count": int(row.get("view_count") or 0),
|
||||
"last_view_time": row.get("last_view_time"),
|
||||
"owner_user_id": owner_id or "",
|
||||
"owner_user_name": _str_or_none(row.get("owner_user_name"), 50),
|
||||
"supplier_id": legacy_id_to_str(row.get("supplier_id")),
|
||||
"supplier_name": _str_or_none(row.get("supplier_name"), 200),
|
||||
"contract_id": legacy_id_to_str(row.get("contract_id")),
|
||||
"payment_terms": _str_or_none(row.get("payment_terms"), 500),
|
||||
"delivery_terms": _str_or_none(row.get("delivery_terms"), 500),
|
||||
"freight": _dec(row.get("freight"), Decimal("0.00")),
|
||||
"attachments": normalize_attachment_csv(row.get("attachments"), oss_map),
|
||||
"template_content": row.get("template_content"),
|
||||
"version_no": int(row.get("version_no") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_quote_product(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"quote_product#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"quote_id": legacy_id_to_str(row.get("quote_id")) or "",
|
||||
"product_id": legacy_id_to_str(row.get("product_id")) or "",
|
||||
"product_name": (row.get("product_name") or "")[:200],
|
||||
"product_no": _str_or_none(row.get("product_no"), 50),
|
||||
"category_id": legacy_id_to_str(row.get("category_id")),
|
||||
"category_name": _str_or_none(row.get("category_name"), 100),
|
||||
"model": _str_or_none(row.get("model"), 100),
|
||||
"unit": (row.get("unit") or "件")[:20],
|
||||
"quantity": _dec(row.get("quantity"), Decimal("0")),
|
||||
"price": _dec(row.get("price"), Decimal("0.00")),
|
||||
"discount": _dec(row.get("discount"), Decimal("0.00")),
|
||||
"subtotal": _dec(row.get("subtotal"), Decimal("0.00")),
|
||||
"tax_amount": _dec(row.get("tax_amount"), Decimal("0.00")),
|
||||
"tax_rate": _dec(row.get("tax_rate"), Decimal("13.00")),
|
||||
"package_spec": _str_or_none(row.get("package_spec"), 200),
|
||||
"delivery_method": _str_or_none(row.get("delivery_method"), 200),
|
||||
"description": _str_or_none(row.get("description"), 1000),
|
||||
"sort": int(row.get("sort") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_quote_approval(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
label = f"quote_approval#{rid}"
|
||||
|
||||
def map_user(field_val: Any) -> Optional[str]:
|
||||
return resolve_portal_user_id(field_val, crm_user_map, admin_id, stats, label)
|
||||
|
||||
return {
|
||||
"id": rid,
|
||||
"quote_id": legacy_id_to_str(row.get("quote_id")) or "",
|
||||
"quote_no": _str_or_none(row.get("quote_no"), 50),
|
||||
"step": int(row.get("step") or 1),
|
||||
"approver_id": map_user(row.get("approver_id")) or "",
|
||||
"approver_name": _str_or_none(row.get("approver_name"), 50),
|
||||
"approval_result": (row.get("approval_result") or "pending")[:20],
|
||||
"approval_opinion": row.get("approval_opinion"),
|
||||
"approval_time": row.get("approval_time"),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": _str_or_none(row.get("remark"), 500),
|
||||
"create_by": map_user(row.get("create_by")),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": map_user(row.get("update_by")),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": int(row.get("del_flag") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_quote_share_log(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
rid = legacy_id_to_str(row["id"])
|
||||
return {
|
||||
"id": rid,
|
||||
"quote_id": legacy_id_to_str(row.get("quote_id")) or "",
|
||||
"share_code": (row.get("share_code") or "")[:50],
|
||||
"visit_time": row.get("visit_time") or row.get("create_time") or datetime.now(),
|
||||
"ip_address": _str_or_none(row.get("ip_address"), 50),
|
||||
"location": _str_or_none(row.get("location"), 200),
|
||||
"device_type": _str_or_none(row.get("device_type"), 50),
|
||||
"browser": _str_or_none(row.get("browser"), 100),
|
||||
"stay_duration": int(row.get("stay_duration") or 0),
|
||||
"page_views": int(row.get("page_views") or 1),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": 0,
|
||||
"remark": None,
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or row.get("visit_time") or datetime.now(),
|
||||
"update_by": None,
|
||||
"update_time": row.get("create_time") or row.get("visit_time") or datetime.now(),
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
INSERT_SUPPLIER_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_supplier_info (
|
||||
id, supplier_name, short_name, supplier_code, supplier_address, legal_person, agent,
|
||||
phone_fax, bank_name, bank_account, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :supplier_name, :short_name, :supplier_code, :supplier_address, :legal_person, :agent,
|
||||
:phone_fax, :bank_name, :bank_account, :status, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_QUOTE_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_quote (
|
||||
id, quote_no, quote_title, quote_type, customer_id, customer_name, opportunity_id,
|
||||
contact_id, contact_name, contact_mobile, contact_email, version, parent_id, is_latest,
|
||||
total_amount, discount_rate, discount_amount, final_amount, validity_days, quote_date,
|
||||
expire_date, status, reject_reason, pdf_url, pdf_generated, share_link, share_code,
|
||||
share_password, share_expire_time, view_count, last_view_time, owner_user_id, owner_user_name,
|
||||
supplier_id, supplier_name, contract_id, payment_terms, delivery_terms, freight,
|
||||
attachments, template_content, version_no, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :quote_no, :quote_title, :quote_type, :customer_id, :customer_name, :opportunity_id,
|
||||
:contact_id, :contact_name, :contact_mobile, :contact_email, :version, :parent_id, :is_latest,
|
||||
:total_amount, :discount_rate, :discount_amount, :final_amount, :validity_days, :quote_date,
|
||||
:expire_date, :status, :reject_reason, :pdf_url, :pdf_generated, :share_link, :share_code,
|
||||
:share_password, :share_expire_time, :view_count, :last_view_time, :owner_user_id, :owner_user_name,
|
||||
:supplier_id, :supplier_name, :contract_id, :payment_terms, :delivery_terms, :freight,
|
||||
:attachments, :template_content, :version_no, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_QUOTE_PRODUCT_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_quote_product (
|
||||
id, quote_id, product_id, product_name, product_no, category_id, category_name,
|
||||
model, unit, quantity, price, discount, subtotal, tax_amount, tax_rate,
|
||||
package_spec, delivery_method, description, sort,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :quote_id, :product_id, :product_name, :product_no, :category_id, :category_name,
|
||||
:model, :unit, :quantity, :price, :discount, :subtotal, :tax_amount, :tax_rate,
|
||||
:package_spec, :delivery_method, :description, :sort,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_QUOTE_APPROVAL_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_quote_approval (
|
||||
id, quote_id, quote_no, step, approver_id, approver_name, approval_result,
|
||||
approval_opinion, approval_time, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :quote_id, :quote_no, :step, :approver_id, :approver_name, :approval_result,
|
||||
:approval_opinion, :approval_time, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
INSERT_QUOTE_SHARE_LOG_SQL = text(
|
||||
"""
|
||||
INSERT INTO crm_quote_share_log (
|
||||
id, quote_id, share_code, visit_time, ip_address, location, device_type, browser,
|
||||
stay_duration, page_views, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :quote_id, :share_code, :visit_time, :ip_address, :location, :device_type, :browser,
|
||||
:stay_duration, :page_views, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
STEP_INSERT_SQL = {
|
||||
"crm_supplier_info": INSERT_SUPPLIER_SQL,
|
||||
"crm_quote": INSERT_QUOTE_SQL,
|
||||
"crm_quote_product": INSERT_QUOTE_PRODUCT_SQL,
|
||||
"crm_quote_approval": INSERT_QUOTE_APPROVAL_SQL,
|
||||
"crm_quote_share_log": INSERT_QUOTE_SHARE_LOG_SQL,
|
||||
}
|
||||
|
||||
|
||||
def write_mapping_csv(source_rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["source_id", "new_id", "migrated_at"])
|
||||
w.writeheader()
|
||||
for row in source_rows:
|
||||
sid = legacy_id_to_str(row["id"])
|
||||
w.writerow({"source_id": sid, "new_id": sid, "migrated_at": migrated_at})
|
||||
|
||||
|
||||
def migrate_table(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
table: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
oss_map: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
) -> List[dict]:
|
||||
step = stats.step(table)
|
||||
source_rows = fetch_source_rows(source_engine, table, include_deleted)
|
||||
step.source_count = len(source_rows)
|
||||
|
||||
transformers = {
|
||||
"crm_supplier_info": lambda r: transform_supplier_info(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_quote": lambda r: transform_quote(r, tenant_no, crm_user_map, admin_id, oss_map, stats),
|
||||
"crm_quote_product": lambda r: transform_quote_product(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_quote_approval": lambda r: transform_quote_approval(r, tenant_no, crm_user_map, admin_id, stats),
|
||||
"crm_quote_share_log": lambda r: transform_quote_share_log(r, tenant_no, stats),
|
||||
}
|
||||
payloads = [transformers[table](r) for r in source_rows]
|
||||
|
||||
if dry_run:
|
||||
step.inserted = len(payloads)
|
||||
return source_rows
|
||||
|
||||
insert_sql = STEP_INSERT_SQL[table]
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, table, rid):
|
||||
if update_existing:
|
||||
step.skipped += 1
|
||||
else:
|
||||
step.skipped += 1
|
||||
else:
|
||||
conn.execute(insert_sql, payload)
|
||||
step.inserted += 1
|
||||
return source_rows
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM 报价单迁移 [{mode}] ===")
|
||||
for table, _ in MIGRATION_STEPS:
|
||||
s = stats.step(table)
|
||||
print(f"{table}: 源={s.source_count} 插入={s.inserted} 跳过={s.skipped}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:20]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_dir: Path,
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
include_deleted: bool,
|
||||
only_tables: Optional[List[str]],
|
||||
skip_supplier: bool,
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
oss_map = load_oss_id_to_url(target_engine, tenant_no)
|
||||
print(f"sys_oss 映射条数: {len(oss_map)}")
|
||||
|
||||
selected = [t for t, _ in MIGRATION_STEPS if not only_tables or t in only_tables]
|
||||
if skip_supplier and "crm_supplier_info" in selected:
|
||||
selected = [t for t in selected if t != "crm_supplier_info"]
|
||||
|
||||
for table in selected:
|
||||
print(f"\n>> 迁移 {table} ...")
|
||||
source_rows = migrate_table(
|
||||
source_engine,
|
||||
target_engine,
|
||||
table,
|
||||
tenant_no=tenant_no,
|
||||
crm_user_map=crm_user_map,
|
||||
admin_id=admin_id,
|
||||
oss_map=oss_map,
|
||||
stats=stats,
|
||||
dry_run=dry_run,
|
||||
update_existing=update_existing,
|
||||
include_deleted=include_deleted,
|
||||
)
|
||||
if table == "crm_quote" and not dry_run:
|
||||
write_mapping_csv(source_rows, mapping_dir / "crm_quote_id_mapping.csv")
|
||||
|
||||
print_report(stats, dry_run)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM quote tables from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true")
|
||||
parser.add_argument("--include-deleted", action="store_true")
|
||||
parser.add_argument("--skip-supplier", action="store_true", help="跳过 crm_supplier_info 前置迁移")
|
||||
parser.add_argument("--tables", default="", help="逗号分隔,默认全部表")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
only_tables = [t.strip() for t in args.tables.split(",") if t.strip()] or None
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_dir=BACKEND_ROOT.parent / "docs/data-migration/mappings",
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
include_deleted=args.include_deleted,
|
||||
only_tables=only_tables,
|
||||
skip_supplier=args.skip_supplier,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,638 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库 CRM sys_oss 迁入 shanxia2.0,并反查业务表回填 biz_type/biz_id。
|
||||
|
||||
旧 CRM ``sys_oss`` 无 ``biz_type``/``biz_id``;门户新增这两列。迁移分两步:
|
||||
1. 整表 INSERT(id=str(oss_id),url 绝对路径→相对 object key)
|
||||
2. 扫描业务表 OSS ID 引用,UPDATE 回填 biz;反查不到则保持 NULL(不用 sys_oper_log)
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/migrate_crm_sys_oss.py --dry-run --env-file .env.production
|
||||
|
||||
venv/bin/python scripts/migrate_crm_sys_oss.py --env-file .env.production \\
|
||||
--user-mapping ../docs/data-migration/mappings/user_mapping_result.csv \\
|
||||
--mapping-out ../docs/data-migration/mappings/crm_oss_id_mapping.csv \\
|
||||
--backfill-out ../docs/data-migration/mappings/crm_oss_biz_backfill.csv \\
|
||||
--unresolved-out ../docs/data-migration/mappings/crm_oss_unresolved.csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
MIGRATION_DIR = BACKEND_ROOT / "scripts" / "migration"
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
if str(MIGRATION_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(MIGRATION_DIR))
|
||||
|
||||
from minio_url import normalize_oss_storage_url # noqa: E402
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
SOURCE_TABLE = "sys_oss"
|
||||
TARGET_TABLE = "sys_oss"
|
||||
CRM_ADMIN_SOURCE_USER_ID = 1
|
||||
|
||||
OSS_ID_RE = re.compile(r"^\d{1,20}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackfillRule:
|
||||
table: str
|
||||
id_column: str
|
||||
field: str
|
||||
biz_type: str
|
||||
attach_type: Optional[str] = None
|
||||
value_kind: str = "csv" # csv | json_array | bigint
|
||||
|
||||
|
||||
BACKFILL_RULES: List[BackfillRule] = [
|
||||
BackfillRule("crm_customer", "id", "business_license_images", "crm_customer", "business_license"),
|
||||
BackfillRule("crm_customer", "id", "business_permit_images", "crm_customer", "business_permit"),
|
||||
BackfillRule("crm_customer", "id", "other_cert_images", "crm_customer", "other_cert"),
|
||||
BackfillRule("crm_customer_follow", "id", "attachments", "crm_customer_follow"),
|
||||
BackfillRule("crm_clue_follow", "id", "attachments", "crm_clue_follow"),
|
||||
BackfillRule("crm_opportunity_follow", "id", "attachments", "crm_opportunity_follow"),
|
||||
BackfillRule("crm_quote", "id", "attachments", "crm_quote"),
|
||||
BackfillRule("crm_contract", "id", "attachment_files", "crm_contract"),
|
||||
BackfillRule("crm_contract", "id", "contract_file", "crm_contract", "contract_file"),
|
||||
BackfillRule("crm_product", "id", "images", "crm_product", "image"),
|
||||
BackfillRule(
|
||||
"crm_contract_product_price_tier",
|
||||
"id",
|
||||
"letter_oss_id",
|
||||
"crm_contract_product_price_tier",
|
||||
"letter",
|
||||
value_kind="bigint",
|
||||
),
|
||||
BackfillRule(
|
||||
"crm_contract_product_price_tier",
|
||||
"id",
|
||||
"letter_oss_ids",
|
||||
"crm_contract_product_price_tier",
|
||||
"letter",
|
||||
value_kind="json_array",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BizRef:
|
||||
biz_type: str
|
||||
biz_id: str
|
||||
source_table: str
|
||||
source_field: str
|
||||
attach_type: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
source_count: int = 0
|
||||
inserted: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
backfilled: int = 0
|
||||
unresolved: int = 0
|
||||
conflicts: int = 0
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: Any) -> Optional[str]:
|
||||
if legacy_id is None or str(legacy_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return str(int(legacy_id))
|
||||
except (TypeError, ValueError):
|
||||
return str(legacy_id).strip()
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
return urlunparse(urlparse(target_url)._replace(path=f"/{source_db}"))
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_user_mapping(path: Path) -> Tuple[Dict[int, str], Optional[str]]:
|
||||
crm_map: Dict[int, str] = {}
|
||||
admin_id: Optional[str] = None
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
for row in csv.DictReader(f):
|
||||
new_id = (row.get("new_user_id") or "").strip()
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
crm_raw = (row.get("crm_user_id") or "").strip()
|
||||
if user_no == "admin" and new_id:
|
||||
admin_id = new_id
|
||||
if crm_raw and new_id:
|
||||
try:
|
||||
crm_map[int(crm_raw)] = new_id
|
||||
except ValueError:
|
||||
pass
|
||||
return crm_map, admin_id
|
||||
|
||||
|
||||
def fetch_portal_admin_id(target_engine: Engine) -> Optional[str]:
|
||||
with target_engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no='admin' AND del_flag=0 LIMIT 1")
|
||||
).first()
|
||||
return str(row[0]) if row and row[0] else None
|
||||
|
||||
|
||||
def resolve_portal_user_id(
|
||||
source_user_id: Any,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
) -> Optional[str]:
|
||||
if source_user_id is None or str(source_user_id).strip() == "":
|
||||
return None
|
||||
try:
|
||||
uid = int(source_user_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.warnings.append(f"{label}: 非法用户 id {source_user_id!r}")
|
||||
return None
|
||||
if uid == CRM_ADMIN_SOURCE_USER_ID:
|
||||
return admin_id
|
||||
mapped = crm_user_map.get(uid)
|
||||
if not mapped:
|
||||
stats.warnings.append(f"{label}: crm_user_id={uid} 无映射")
|
||||
return mapped
|
||||
|
||||
|
||||
def fetch_source_rows(engine: Engine) -> List[dict]:
|
||||
sql = f"SELECT * FROM `{SOURCE_TABLE}` ORDER BY oss_id ASC"
|
||||
with engine.connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(text(sql))]
|
||||
|
||||
|
||||
def count_target_rows(engine: Engine, tenant_no: str) -> int:
|
||||
with engine.connect() as conn:
|
||||
return int(
|
||||
conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE tenant_no=:t"),
|
||||
{"t": tenant_no},
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def transform_row(
|
||||
row: dict,
|
||||
tenant_no: str,
|
||||
crm_user_map: Dict[int, str],
|
||||
admin_id: Optional[str],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
oss_id = legacy_id_to_str(row["oss_id"])
|
||||
relative_url = normalize_oss_storage_url(row.get("url"), row.get("file_name"))
|
||||
if not relative_url:
|
||||
stats.warnings.append(f"oss#{oss_id}: url 无法规范化为相对路径")
|
||||
|
||||
return {
|
||||
"id": oss_id,
|
||||
"tenant_no": tenant_no,
|
||||
"biz_type": None,
|
||||
"biz_id": None,
|
||||
"file_name": (row.get("file_name") or "")[:255],
|
||||
"original_name": (row.get("original_name") or "")[:255],
|
||||
"file_suffix": (row.get("file_suffix") or "")[:10],
|
||||
"url": (relative_url or (row.get("file_name") or ""))[:500],
|
||||
"file_size": None,
|
||||
"file_type": None,
|
||||
"attach_type": "attachment",
|
||||
"sort_order": 0,
|
||||
"service": (row.get("service") or "minio")[:20],
|
||||
"ext1": row.get("ext1"),
|
||||
"order_num": 0,
|
||||
"remark": None,
|
||||
"create_by": resolve_portal_user_id(
|
||||
row.get("create_by"), crm_user_map, admin_id, stats, f"oss#{oss_id} create_by"
|
||||
),
|
||||
"create_dept_id": "",
|
||||
"create_time": row.get("create_time") or datetime.now(),
|
||||
"update_by": resolve_portal_user_id(
|
||||
row.get("update_by"), crm_user_map, admin_id, stats, f"oss#{oss_id} update_by"
|
||||
),
|
||||
"update_time": row.get("update_time") or row.get("create_time") or datetime.now(),
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
INSERT_SQL = text(
|
||||
f"""
|
||||
INSERT INTO `{TARGET_TABLE}` (
|
||||
id, tenant_no, biz_type, biz_id, file_name, original_name, file_suffix, url,
|
||||
file_size, file_type, attach_type, sort_order, service, ext1, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :tenant_no, :biz_type, :biz_id, :file_name, :original_name, :file_suffix, :url,
|
||||
:file_size, :file_type, :attach_type, :sort_order, :service, :ext1, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
UPDATE_SQL = text(
|
||||
f"""
|
||||
UPDATE `{TARGET_TABLE}` SET
|
||||
file_name=:file_name, original_name=:original_name, file_suffix=:file_suffix, url=:url,
|
||||
service=:service, ext1=:ext1, tenant_no=:tenant_no,
|
||||
create_by=:create_by, update_by=:update_by, update_time=:update_time
|
||||
WHERE id=:id
|
||||
"""
|
||||
)
|
||||
|
||||
UPDATE_BIZ_SQL = text(
|
||||
f"""
|
||||
UPDATE `{TARGET_TABLE}` SET
|
||||
biz_type=:biz_type, biz_id=:biz_id, attach_type=:attach_type, update_time=:update_time
|
||||
WHERE id=:id AND tenant_no=:tenant_no
|
||||
AND (biz_type IS NULL OR biz_type = '')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def target_row_exists(conn, row_id: str) -> bool:
|
||||
return (
|
||||
int(conn.execute(text(f"SELECT COUNT(*) FROM `{TARGET_TABLE}` WHERE id=:id"), {"id": row_id}).scalar_one())
|
||||
> 0
|
||||
)
|
||||
|
||||
|
||||
def parse_oss_id_tokens(raw: Any, *, value_kind: str = "csv") -> List[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if value_kind == "bigint":
|
||||
sid = legacy_id_to_str(raw)
|
||||
return [sid] if sid and OSS_ID_RE.match(sid) else []
|
||||
|
||||
text_val = str(raw).strip()
|
||||
if not text_val:
|
||||
return []
|
||||
|
||||
if value_kind == "json_array":
|
||||
try:
|
||||
parsed = json.loads(text_val)
|
||||
if isinstance(parsed, list):
|
||||
return [t for item in parsed if (t := _normalize_oss_token(str(item)))]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return []
|
||||
|
||||
tokens: List[str] = []
|
||||
for part in text_val.split(","):
|
||||
token = _normalize_oss_token(part)
|
||||
if token:
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _normalize_oss_token(part: str) -> Optional[str]:
|
||||
token = (part or "").strip().strip('"').strip("'")
|
||||
if not token:
|
||||
return None
|
||||
if token.startswith("http://") or token.startswith("https://"):
|
||||
return None
|
||||
if "/" in token or "\\" in token:
|
||||
return None
|
||||
if not OSS_ID_RE.match(token):
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def table_exists(engine: Engine, table_name: str) -> bool:
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM information_schema.tables "
|
||||
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||
),
|
||||
{"t": table_name},
|
||||
).scalar_one()
|
||||
return int(row) > 0
|
||||
|
||||
|
||||
def build_biz_index(
|
||||
source_engine: Engine,
|
||||
known_oss_ids: Set[str],
|
||||
stats: MigrationStats,
|
||||
) -> Tuple[Dict[str, BizRef], List[dict]]:
|
||||
index: Dict[str, BizRef] = {}
|
||||
conflicts: List[dict] = []
|
||||
|
||||
for rule in BACKFILL_RULES:
|
||||
if not table_exists(source_engine, rule.table):
|
||||
stats.warnings.append(f"回填跳过:源库无表 {rule.table}")
|
||||
continue
|
||||
sql = f"SELECT `{rule.id_column}` AS biz_row_id, `{rule.field}` AS field_value FROM `{rule.table}`"
|
||||
with source_engine.connect() as conn:
|
||||
rows = conn.execute(text(sql)).mappings().all()
|
||||
for row in rows:
|
||||
biz_id = legacy_id_to_str(row["biz_row_id"])
|
||||
if not biz_id:
|
||||
continue
|
||||
for oss_id in parse_oss_id_tokens(row["field_value"], value_kind=rule.value_kind):
|
||||
if oss_id not in known_oss_ids:
|
||||
continue
|
||||
ref = BizRef(
|
||||
biz_type=rule.biz_type,
|
||||
biz_id=biz_id,
|
||||
source_table=rule.table,
|
||||
source_field=rule.field,
|
||||
attach_type=rule.attach_type,
|
||||
)
|
||||
existing = index.get(oss_id)
|
||||
if existing is None:
|
||||
index[oss_id] = ref
|
||||
continue
|
||||
if (
|
||||
existing.biz_type == ref.biz_type
|
||||
and existing.biz_id == ref.biz_id
|
||||
and existing.attach_type == ref.attach_type
|
||||
):
|
||||
continue
|
||||
conflicts.append(
|
||||
{
|
||||
"oss_id": oss_id,
|
||||
"kept_biz_type": existing.biz_type,
|
||||
"kept_biz_id": existing.biz_id,
|
||||
"kept_source": f"{existing.source_table}.{existing.source_field}",
|
||||
"ignored_biz_type": ref.biz_type,
|
||||
"ignored_biz_id": ref.biz_id,
|
||||
"ignored_source": f"{ref.source_table}.{ref.field}",
|
||||
}
|
||||
)
|
||||
stats.conflicts += 1
|
||||
|
||||
return index, conflicts
|
||||
|
||||
|
||||
def apply_biz_backfill(
|
||||
target_engine: Engine,
|
||||
biz_index: Dict[str, BizRef],
|
||||
tenant_no: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
if dry_run or not biz_index:
|
||||
return len(biz_index)
|
||||
|
||||
now = datetime.now()
|
||||
updated = 0
|
||||
with target_engine.begin() as conn:
|
||||
for oss_id, ref in biz_index.items():
|
||||
result = conn.execute(
|
||||
UPDATE_BIZ_SQL,
|
||||
{
|
||||
"id": oss_id,
|
||||
"tenant_no": tenant_no,
|
||||
"biz_type": ref.biz_type,
|
||||
"biz_id": ref.biz_id,
|
||||
"attach_type": ref.attach_type or "attachment",
|
||||
"update_time": now,
|
||||
},
|
||||
)
|
||||
if result.rowcount:
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def write_mapping_csv(rows: List[dict], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
migrated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(
|
||||
f,
|
||||
fieldnames=[
|
||||
"source_oss_id",
|
||||
"new_id",
|
||||
"file_name",
|
||||
"original_name",
|
||||
"url_relative",
|
||||
"migrated_at",
|
||||
],
|
||||
)
|
||||
w.writeheader()
|
||||
for row in rows:
|
||||
sid = legacy_id_to_str(row["oss_id"])
|
||||
w.writerow(
|
||||
{
|
||||
"source_oss_id": sid,
|
||||
"new_id": sid,
|
||||
"file_name": row.get("file_name") or "",
|
||||
"original_name": row.get("original_name") or "",
|
||||
"url_relative": normalize_oss_storage_url(row.get("url"), row.get("file_name")),
|
||||
"migrated_at": migrated_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def write_backfill_csv(biz_index: Dict[str, BizRef], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(
|
||||
f,
|
||||
fieldnames=["oss_id", "biz_type", "biz_id", "attach_type", "source_table", "source_field"],
|
||||
)
|
||||
w.writeheader()
|
||||
for oss_id in sorted(biz_index.keys(), key=lambda x: int(x)):
|
||||
ref = biz_index[oss_id]
|
||||
w.writerow(
|
||||
{
|
||||
"oss_id": oss_id,
|
||||
"biz_type": ref.biz_type,
|
||||
"biz_id": ref.biz_id,
|
||||
"attach_type": ref.attach_type or "",
|
||||
"source_table": ref.source_table,
|
||||
"source_field": ref.source_field,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def write_unresolved_csv(
|
||||
all_oss_ids: Iterable[str],
|
||||
biz_index: Dict[str, BizRef],
|
||||
path: Path,
|
||||
) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
unresolved = sorted(set(all_oss_ids) - set(biz_index.keys()), key=lambda x: int(x))
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=["oss_id", "reason"])
|
||||
w.writeheader()
|
||||
for oss_id in unresolved:
|
||||
w.writerow({"oss_id": oss_id, "reason": "no_business_reference"})
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "COMMITTED"
|
||||
print(f"\n=== CRM sys_oss 迁移 [{mode}] ===")
|
||||
print(f"源表行数: {stats.source_count}")
|
||||
print(f"插入: {stats.inserted} 更新: {stats.updated} 跳过: {stats.skipped}")
|
||||
print(f"biz 回填: {stats.backfilled} 未解析 biz: {stats.unresolved} 冲突(保留首条): {stats.conflicts}")
|
||||
if stats.warnings:
|
||||
print(f"警告 ({len(stats.warnings)}):")
|
||||
for w in stats.warnings[:15]:
|
||||
print(f" - {w}")
|
||||
if len(stats.warnings) > 15:
|
||||
print(f" ... 另有 {len(stats.warnings) - 15} 条")
|
||||
|
||||
|
||||
def run_migration(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_mapping_path: Path,
|
||||
mapping_out: Optional[Path],
|
||||
backfill_out: Optional[Path],
|
||||
unresolved_out: Optional[Path],
|
||||
dry_run: bool,
|
||||
update_existing: bool,
|
||||
skip_insert: bool,
|
||||
skip_backfill: bool,
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
crm_user_map, admin_id = load_user_mapping(user_mapping_path)
|
||||
if not admin_id:
|
||||
admin_id = fetch_portal_admin_id(target_engine)
|
||||
|
||||
source_rows = fetch_source_rows(source_engine)
|
||||
stats.source_count = len(source_rows)
|
||||
known_oss_ids = {legacy_id_to_str(r["oss_id"]) for r in source_rows if legacy_id_to_str(r["oss_id"])}
|
||||
|
||||
payloads = [transform_row(r, tenant_no, crm_user_map, admin_id, stats) for r in source_rows]
|
||||
|
||||
if not skip_insert:
|
||||
if dry_run:
|
||||
print("\n预览(前 5 条):")
|
||||
for p in payloads[:5]:
|
||||
print(
|
||||
f" id={p['id']} url={p['url']!r} file={p['file_name']!r} "
|
||||
f"biz=({p['biz_type']},{p['biz_id']})"
|
||||
)
|
||||
stats.inserted = len(payloads)
|
||||
else:
|
||||
with target_engine.begin() as conn:
|
||||
for payload in payloads:
|
||||
rid = payload["id"]
|
||||
if target_row_exists(conn, rid):
|
||||
if update_existing:
|
||||
conn.execute(UPDATE_SQL, payload)
|
||||
stats.updated += 1
|
||||
else:
|
||||
stats.skipped += 1
|
||||
else:
|
||||
conn.execute(INSERT_SQL, payload)
|
||||
stats.inserted += 1
|
||||
|
||||
biz_index: Dict[str, BizRef] = {}
|
||||
if not skip_backfill:
|
||||
biz_index, conflicts = build_biz_index(source_engine, known_oss_ids, stats)
|
||||
if conflicts and dry_run:
|
||||
print(f"\n回填冲突预览(前 3 条,共 {len(conflicts)}):")
|
||||
for item in conflicts[:3]:
|
||||
print(f" oss={item['oss_id']} kept={item['kept_source']} ignored={item['ignored_source']}")
|
||||
stats.backfilled = apply_biz_backfill(target_engine, biz_index, tenant_no, dry_run=dry_run)
|
||||
stats.unresolved = len(known_oss_ids) - len(biz_index)
|
||||
|
||||
if mapping_out and not dry_run:
|
||||
write_mapping_csv(source_rows, mapping_out)
|
||||
print(f"\nID 映射已写入: {mapping_out}")
|
||||
if backfill_out:
|
||||
write_backfill_csv(biz_index, backfill_out)
|
||||
print(f"biz 回填映射已写入: {backfill_out}")
|
||||
if unresolved_out:
|
||||
write_unresolved_csv(known_oss_ids, biz_index, unresolved_out)
|
||||
print(f"未回填 oss 列表已写入: {unresolved_out}")
|
||||
|
||||
print_report(stats, dry_run)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate CRM sys_oss from sx to portal")
|
||||
parser.add_argument("--source-url", default="")
|
||||
parser.add_argument("--target-url", default="")
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
parser.add_argument(
|
||||
"--user-mapping",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--update", action="store_true", help="目标已存在同 id 时更新元数据(不含 biz 覆盖)")
|
||||
parser.add_argument("--skip-insert", action="store_true", help="仅执行 biz 回填")
|
||||
parser.add_argument("--skip-backfill", action="store_true", help="仅 INSERT,不回填 biz_type/biz_id")
|
||||
parser.add_argument("--mapping-out", default="")
|
||||
parser.add_argument("--backfill-out", default="")
|
||||
parser.add_argument("--unresolved-out", default="")
|
||||
parser.add_argument("--env-file", default=os.environ.get("ENV_FILE", ""))
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file or None)
|
||||
settings = load_runtime_settings()
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
source_url = args.source_url or os.environ.get("SX_SOURCE_DATABASE_URL") or derive_source_url(target_url)
|
||||
|
||||
print(f"源库: {urlparse(source_url).path}")
|
||||
print(f"目标库: {urlparse(target_url).path}")
|
||||
print("数据源: sx.sys_oss(旧 CRM 附件元数据,共用 MinIO)")
|
||||
print(f"目标表现有 sys_oss: {count_target_rows(create_engine(target_url), args.tenant_no)}")
|
||||
|
||||
default_mapping_dir = BACKEND_ROOT.parent / "docs/data-migration/mappings"
|
||||
mapping_out = Path(args.mapping_out) if args.mapping_out else default_mapping_dir / "crm_oss_id_mapping.csv"
|
||||
backfill_out = Path(args.backfill_out) if args.backfill_out else default_mapping_dir / "crm_oss_biz_backfill.csv"
|
||||
unresolved_out = (
|
||||
Path(args.unresolved_out) if args.unresolved_out else default_mapping_dir / "crm_oss_unresolved.csv"
|
||||
)
|
||||
|
||||
run_migration(
|
||||
create_engine(source_url, pool_pre_ping=True),
|
||||
create_engine(target_url, pool_pre_ping=True),
|
||||
tenant_no=args.tenant_no,
|
||||
user_mapping_path=Path(args.user_mapping),
|
||||
mapping_out=mapping_out if not args.dry_run else None,
|
||||
backfill_out=backfill_out if not args.skip_backfill else None,
|
||||
unresolved_out=unresolved_out if not args.skip_backfill else None,
|
||||
dry_run=args.dry_run,
|
||||
update_existing=args.update,
|
||||
skip_insert=args.skip_insert,
|
||||
skip_backfill=args.skip_backfill,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,569 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate legacy dibang business data into shanxia2.0 db_* tables.
|
||||
|
||||
Reads from the original three tables (purchase_plans, inbound_outbound,
|
||||
weighing_slips), preserves legacy int IDs as VARCHAR primary keys (e.g. 12 → "12"),
|
||||
remaps foreign keys, and writes portal public fields (tenant_no, del_flag, etc.).
|
||||
|
||||
Usage:
|
||||
cd backend && source venv/bin/activate
|
||||
python scripts/migrate_dibang_data.py --dry-run \\
|
||||
--source-url mysql+pymysql://user:pass@127.0.0.1:3306/dibang
|
||||
|
||||
python scripts/migrate_dibang_data.py \\
|
||||
--source-url mysql+pymysql://user:pass@127.0.0.1:3306/dibang \\
|
||||
--mapping-out ./dibang-id-map.json
|
||||
|
||||
Environment:
|
||||
DIBANG_SOURCE_DATABASE_URL default for --source-url
|
||||
DATABASE_URL target DB (from app settings / .env)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
SOURCE_TABLES = {
|
||||
"purchase_plans": "purchase_plans",
|
||||
"inbound_outbound": "inbound_outbound",
|
||||
"weighing_slips": "weighing_slips",
|
||||
}
|
||||
|
||||
TARGET_TABLES = {
|
||||
"purchase_plans": "db_purchase_plan",
|
||||
"inbound_outbound": "db_inbound_outbound",
|
||||
"weighing_slips": "db_weighing_slip",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
source_counts: Dict[str, int] = field(default_factory=dict)
|
||||
inserted: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
|
||||
skipped: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
orphan_fks: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_date(value: Any) -> Optional[date]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, date) and not isinstance(value, datetime):
|
||||
return value
|
||||
text_value = str(value).strip()
|
||||
if not text_value:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
|
||||
try:
|
||||
return datetime.strptime(text_value[:10], fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ValueError(f"无法解析日期: {value!r}")
|
||||
|
||||
|
||||
def parse_datetime(value: Any) -> Optional[datetime]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
text_value = str(value).strip()
|
||||
if not text_value:
|
||||
return None
|
||||
formats: Iterable[Tuple[str, int]] = (
|
||||
("%Y-%m-%d %H:%M:%S", 19),
|
||||
("%Y-%m-%d %H:%M", 16),
|
||||
("%Y-%m-%d", 10),
|
||||
("%Y/%m/%d %H:%M:%S", 19),
|
||||
("%Y/%m/%d", 10),
|
||||
)
|
||||
for fmt, length in formats:
|
||||
try:
|
||||
return datetime.strptime(text_value[:length], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
parsed = parse_date(text_value)
|
||||
return datetime.combine(parsed, datetime.min.time()) if parsed else None
|
||||
|
||||
|
||||
def clip_text(value: Any, max_len: int, label: str, stats: MigrationStats) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text_value = str(value)
|
||||
if len(text_value) <= max_len:
|
||||
return text_value
|
||||
stats.warnings.append(f"{label} 截断至 {max_len} 字符")
|
||||
return text_value[:max_len]
|
||||
|
||||
|
||||
def fetch_rows(engine: Engine, table: str) -> List[dict]:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(f"SELECT * FROM `{table}` ORDER BY `id`"))
|
||||
return [dict(row._mapping) for row in result]
|
||||
|
||||
|
||||
def count_rows(engine: Engine, table: str) -> int:
|
||||
with engine.connect() as conn:
|
||||
return int(conn.execute(text(f"SELECT COUNT(*) FROM `{table}`")).scalar_one())
|
||||
|
||||
|
||||
def map_id(
|
||||
table_key: str,
|
||||
legacy_id: Any,
|
||||
id_maps: Dict[str, Dict[int, str]],
|
||||
stats: MigrationStats,
|
||||
label: str,
|
||||
) -> Optional[str]:
|
||||
if legacy_id is None:
|
||||
return None
|
||||
try:
|
||||
legacy_int = int(legacy_id)
|
||||
except (TypeError, ValueError):
|
||||
stats.orphan_fks.append(f"{label}: 非法 legacy id {legacy_id!r}")
|
||||
return None
|
||||
mapped = id_maps.get(table_key, {}).get(legacy_int)
|
||||
if mapped is None:
|
||||
stats.orphan_fks.append(f"{label}: legacy id {legacy_int} 无映射")
|
||||
return mapped
|
||||
|
||||
|
||||
def legacy_id_to_str(legacy_id: int) -> str:
|
||||
"""原地磅 int 主键原样保留,写入门户 VARCHAR(64) 字段。"""
|
||||
return str(int(legacy_id))
|
||||
|
||||
|
||||
def build_id_maps(rows_by_table: Dict[str, List[dict]]) -> Dict[str, Dict[int, str]]:
|
||||
id_maps: Dict[str, Dict[int, str]] = {}
|
||||
for table_key, rows in rows_by_table.items():
|
||||
id_maps[table_key] = {
|
||||
int(row["id"]): legacy_id_to_str(int(row["id"])) for row in rows
|
||||
}
|
||||
return id_maps
|
||||
|
||||
|
||||
def transform_plan(
|
||||
row: dict,
|
||||
new_id: str,
|
||||
tenant_no: str,
|
||||
stats: MigrationStats,
|
||||
*,
|
||||
last_weighing_slip_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
create_time = parse_datetime(row.get("created_at")) or datetime.now()
|
||||
plan_date = parse_date(row.get("plan_date")) or parse_date(row.get("start_date")) or create_time.date()
|
||||
return {
|
||||
"id": new_id,
|
||||
"plan_no": row.get("plan_no"),
|
||||
"plan_date": plan_date,
|
||||
"start_date": parse_date(row.get("start_date")),
|
||||
"end_date": parse_date(row.get("end_date")),
|
||||
"business_type": row["business_type"],
|
||||
"product": row["product"],
|
||||
"specification": row.get("specification"),
|
||||
"planned_quantity": float(row["planned_quantity"]),
|
||||
"vehicle_no": row.get("vehicle_no") or "",
|
||||
"customer_name": row.get("customer_name"),
|
||||
"execution_status": row.get("execution_status"),
|
||||
"executed_at": parse_datetime(row.get("executed_at")),
|
||||
"last_weighing_slip_id": last_weighing_slip_id,
|
||||
"executed_quantity": float(row.get("executed_quantity") or 0),
|
||||
"auto_generate_enabled": int(row.get("auto_generate_enabled") or 0),
|
||||
"auto_generate_end_month": row.get("auto_generate_end_month"),
|
||||
"auto_generate_last_month": row.get("auto_generate_last_month"),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": None,
|
||||
"remark": clip_text(row.get("remarks"), 512, "plan.remark", stats),
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": create_time,
|
||||
"update_by": None,
|
||||
"update_time": create_time,
|
||||
"del_flag": int(row.get("is_deleted") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_inventory(
|
||||
row: dict,
|
||||
new_id: str,
|
||||
tenant_no: str,
|
||||
id_maps: Dict[str, Dict[int, str]],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
create_time = parse_datetime(row.get("created_at")) or datetime.now()
|
||||
return {
|
||||
"id": new_id,
|
||||
"date": parse_date(row.get("date")) or create_time.date(),
|
||||
"partner_company": row["partner_company"],
|
||||
"business_type": row["business_type"],
|
||||
"product": row["product"],
|
||||
"quantity": float(row["quantity"]),
|
||||
"plan_no": row.get("plan_no"),
|
||||
"batch_no": row.get("batch_no"),
|
||||
"vehicle_no": row["vehicle_no"],
|
||||
"status": row.get("status"),
|
||||
"purchase_plan_id": map_id(
|
||||
"purchase_plans",
|
||||
row.get("purchase_plan_id"),
|
||||
id_maps,
|
||||
stats,
|
||||
f"inventory#{row.get('id')} purchase_plan_id",
|
||||
),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": None,
|
||||
"remark": clip_text(row.get("remarks"), 512, "inventory.remark", stats),
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": create_time,
|
||||
"update_by": None,
|
||||
"update_time": create_time,
|
||||
"del_flag": int(row.get("is_deleted") or 0),
|
||||
}
|
||||
|
||||
|
||||
def transform_weighing(
|
||||
row: dict,
|
||||
new_id: str,
|
||||
tenant_no: str,
|
||||
id_maps: Dict[str, Dict[int, str]],
|
||||
stats: MigrationStats,
|
||||
) -> dict:
|
||||
create_time = parse_datetime(row.get("created_at")) or datetime.now()
|
||||
gross_time = parse_datetime(row.get("gross_weight_time")) or create_time
|
||||
tare_time = parse_datetime(row.get("tare_weight_time"))
|
||||
return {
|
||||
"id": new_id,
|
||||
"vehicle_no": row["vehicle_no"],
|
||||
"plan_no": row.get("plan_no"),
|
||||
"cargo_name": row["cargo_name"],
|
||||
"business_type": row["business_type"],
|
||||
"batch_no": row.get("batch_no"),
|
||||
"supplier": row["supplier"],
|
||||
"consignee": row["consignee"],
|
||||
"gross_weight": float(row["gross_weight"]),
|
||||
"gross_weight_time": gross_time,
|
||||
"tare_weight": float(row["tare_weight"]),
|
||||
"tare_weight_time": tare_time,
|
||||
"deduction": float(row.get("deduction") or 0),
|
||||
"net_weight": float(row["net_weight"]),
|
||||
"planned_quantity": row.get("planned_quantity"),
|
||||
"executed_quantity": row.get("executed_quantity"),
|
||||
"purchase_plan_id": map_id(
|
||||
"purchase_plans",
|
||||
row.get("purchase_plan_id"),
|
||||
id_maps,
|
||||
stats,
|
||||
f"weighing#{row.get('id')} purchase_plan_id",
|
||||
),
|
||||
"inbound_outbound_id": map_id(
|
||||
"inbound_outbound",
|
||||
row.get("inbound_outbound_id"),
|
||||
id_maps,
|
||||
stats,
|
||||
f"weighing#{row.get('id')} inbound_outbound_id",
|
||||
),
|
||||
"weighing_point": row.get("weighing_point") or "山峡地磅",
|
||||
"current_quantity": row.get("current_quantity"),
|
||||
"is_voided": int(row.get("is_voided") or 0),
|
||||
"tenant_no": tenant_no,
|
||||
"order_num": None,
|
||||
"remark": clip_text(row.get("remarks"), 512, "weighing.remark", stats),
|
||||
"create_by": None,
|
||||
"create_dept_id": "",
|
||||
"create_time": create_time,
|
||||
"update_by": None,
|
||||
"update_time": create_time,
|
||||
"del_flag": 0,
|
||||
}
|
||||
|
||||
|
||||
INSERT_SQL = {
|
||||
"purchase_plans": """
|
||||
INSERT INTO db_purchase_plan (
|
||||
id, plan_no, plan_date, start_date, end_date, business_type, product, specification,
|
||||
planned_quantity, vehicle_no, customer_name, execution_status, executed_at,
|
||||
last_weighing_slip_id, executed_quantity, auto_generate_enabled,
|
||||
auto_generate_end_month, auto_generate_last_month,
|
||||
tenant_no, order_num, remark, create_by, create_dept_id, create_time,
|
||||
update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :plan_no, :plan_date, :start_date, :end_date, :business_type, :product, :specification,
|
||||
:planned_quantity, :vehicle_no, :customer_name, :execution_status, :executed_at,
|
||||
:last_weighing_slip_id, :executed_quantity, :auto_generate_enabled,
|
||||
:auto_generate_end_month, :auto_generate_last_month,
|
||||
:tenant_no, :order_num, :remark, :create_by, :create_dept_id, :create_time,
|
||||
:update_by, :update_time, :del_flag
|
||||
)
|
||||
""",
|
||||
"inbound_outbound": """
|
||||
INSERT INTO db_inbound_outbound (
|
||||
id, date, partner_company, business_type, product, quantity, plan_no, batch_no,
|
||||
vehicle_no, status, purchase_plan_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :date, :partner_company, :business_type, :product, :quantity, :plan_no, :batch_no,
|
||||
:vehicle_no, :status, :purchase_plan_id, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
""",
|
||||
"weighing_slips": """
|
||||
INSERT INTO db_weighing_slip (
|
||||
id, vehicle_no, plan_no, cargo_name, business_type, batch_no, supplier, consignee,
|
||||
gross_weight, gross_weight_time, tare_weight, tare_weight_time, deduction, net_weight,
|
||||
planned_quantity, executed_quantity, purchase_plan_id, inbound_outbound_id,
|
||||
weighing_point, current_quantity, is_voided, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :vehicle_no, :plan_no, :cargo_name, :business_type, :batch_no, :supplier, :consignee,
|
||||
:gross_weight, :gross_weight_time, :tare_weight, :tare_weight_time, :deduction, :net_weight,
|
||||
:planned_quantity, :executed_quantity, :purchase_plan_id, :inbound_outbound_id,
|
||||
:weighing_point, :current_quantity, :is_voided, :tenant_no, :order_num, :remark,
|
||||
:create_by, :create_dept_id, :create_time, :update_by, :update_time, :del_flag
|
||||
)
|
||||
""",
|
||||
}
|
||||
|
||||
UPDATE_PLAN_LAST_WEIGHING_SQL = """
|
||||
UPDATE db_purchase_plan
|
||||
SET last_weighing_slip_id = :last_weighing_slip_id, update_time = :update_time
|
||||
WHERE id = :id
|
||||
"""
|
||||
|
||||
|
||||
def truncate_target_tables(conn, tenant_no: str) -> None:
|
||||
for target in TARGET_TABLES.values():
|
||||
conn.execute(
|
||||
text(f"DELETE FROM `{target}` WHERE tenant_no = :tenant_no"),
|
||||
{"tenant_no": tenant_no},
|
||||
)
|
||||
|
||||
|
||||
def export_mapping(id_maps: Dict[str, Dict[int, str]], path: Path) -> None:
|
||||
payload = {
|
||||
table_key: {str(old_id): new_id for old_id, new_id in mapping.items()}
|
||||
for table_key, mapping in id_maps.items()
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def print_report(stats: MigrationStats, dry_run: bool) -> None:
|
||||
mode = "DRY-RUN" if dry_run else "EXECUTED"
|
||||
print(f"\n=== 地磅历史数据迁移 ({mode}) ===")
|
||||
print("\n源表行数:")
|
||||
for key, count in stats.source_counts.items():
|
||||
print(f" {SOURCE_TABLES[key]}: {count}")
|
||||
|
||||
if not dry_run:
|
||||
print("\n已写入目标表:")
|
||||
for key, count in stats.inserted.items():
|
||||
print(f" {TARGET_TABLES[key]}: {count}")
|
||||
|
||||
if stats.warnings:
|
||||
print(f"\n警告 ({len(stats.warnings)}):")
|
||||
for item in stats.warnings[:20]:
|
||||
print(f" - {item}")
|
||||
if len(stats.warnings) > 20:
|
||||
print(f" ... 另有 {len(stats.warnings) - 20} 条")
|
||||
|
||||
if stats.orphan_fks:
|
||||
print(f"\n关联 ID 缺失 ({len(stats.orphan_fks)}):")
|
||||
for item in stats.orphan_fks[:20]:
|
||||
print(f" - {item}")
|
||||
if len(stats.orphan_fks) > 20:
|
||||
print(f" ... 另有 {len(stats.orphan_fks) - 20} 条")
|
||||
else:
|
||||
print("\n关联 ID 校验: 通过(无 orphan FK)")
|
||||
|
||||
|
||||
def migrate(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
dry_run: bool,
|
||||
truncate_target: bool,
|
||||
include_deleted: bool,
|
||||
mapping_out: Optional[Path],
|
||||
) -> MigrationStats:
|
||||
stats = MigrationStats()
|
||||
rows_by_table: Dict[str, List[dict]] = {}
|
||||
|
||||
for key, source_table in SOURCE_TABLES.items():
|
||||
rows = fetch_rows(source_engine, source_table)
|
||||
if not include_deleted:
|
||||
rows = [row for row in rows if int(row.get("is_deleted") or 0) == 0]
|
||||
rows_by_table[key] = rows
|
||||
stats.source_counts[key] = len(rows)
|
||||
|
||||
id_maps = build_id_maps(rows_by_table)
|
||||
|
||||
transformed: Dict[str, List[dict]] = {
|
||||
"purchase_plans": [
|
||||
transform_plan(
|
||||
row,
|
||||
id_maps["purchase_plans"][int(row["id"])],
|
||||
tenant_no,
|
||||
stats,
|
||||
last_weighing_slip_id=None,
|
||||
)
|
||||
for row in rows_by_table["purchase_plans"]
|
||||
],
|
||||
"inbound_outbound": [
|
||||
transform_inventory(
|
||||
row,
|
||||
id_maps["inbound_outbound"][int(row["id"])],
|
||||
tenant_no,
|
||||
id_maps,
|
||||
stats,
|
||||
)
|
||||
for row in rows_by_table["inbound_outbound"]
|
||||
],
|
||||
"weighing_slips": [
|
||||
transform_weighing(
|
||||
row,
|
||||
id_maps["weighing_slips"][int(row["id"])],
|
||||
tenant_no,
|
||||
id_maps,
|
||||
stats,
|
||||
)
|
||||
for row in rows_by_table["weighing_slips"]
|
||||
],
|
||||
}
|
||||
|
||||
plan_last_weighing_updates: List[Tuple[str, Optional[str], datetime]] = []
|
||||
for row in rows_by_table["purchase_plans"]:
|
||||
legacy_last = row.get("last_weighing_slip_id")
|
||||
if legacy_last is None:
|
||||
continue
|
||||
plan_new_id = id_maps["purchase_plans"][int(row["id"])]
|
||||
weighing_new_id = map_id(
|
||||
"weighing_slips",
|
||||
legacy_last,
|
||||
id_maps,
|
||||
stats,
|
||||
f"plan#{row.get('id')} last_weighing_slip_id",
|
||||
)
|
||||
update_time = parse_datetime(row.get("executed_at")) or parse_datetime(row.get("created_at")) or datetime.now()
|
||||
plan_last_weighing_updates.append((plan_new_id, weighing_new_id, update_time))
|
||||
|
||||
if dry_run:
|
||||
for key, rows in transformed.items():
|
||||
stats.inserted[key] = len(rows)
|
||||
print_report(stats, dry_run=True)
|
||||
return stats
|
||||
|
||||
with target_engine.begin() as conn:
|
||||
if truncate_target:
|
||||
truncate_target_tables(conn, tenant_no)
|
||||
|
||||
for key in ("purchase_plans", "inbound_outbound", "weighing_slips"):
|
||||
for payload in transformed[key]:
|
||||
conn.execute(text(INSERT_SQL[key]), payload)
|
||||
stats.inserted[key] = len(transformed[key])
|
||||
|
||||
for plan_id, weighing_id, update_time in plan_last_weighing_updates:
|
||||
conn.execute(
|
||||
text(UPDATE_PLAN_LAST_WEIGHING_SQL),
|
||||
{
|
||||
"id": plan_id,
|
||||
"last_weighing_slip_id": weighing_id,
|
||||
"update_time": update_time,
|
||||
},
|
||||
)
|
||||
|
||||
if mapping_out:
|
||||
export_mapping(id_maps, mapping_out)
|
||||
print(f"\nID 映射已写入: {mapping_out}")
|
||||
|
||||
print_report(stats, dry_run=False)
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Migrate legacy dibang data into db_* tables")
|
||||
parser.add_argument(
|
||||
"--source-url",
|
||||
default="",
|
||||
help="Source MySQL URL (legacy dibang DB). Falls back to DIBANG_SOURCE_DATABASE_URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-url",
|
||||
default="",
|
||||
help="Target MySQL URL (portal DB). Defaults to app DATABASE_URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tenant-no",
|
||||
default="shanxia2.0",
|
||||
help="Tenant number written to db_* rows",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validate and print counts without writing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--truncate-target",
|
||||
action="store_true",
|
||||
help="Delete existing db_* rows for tenant before import (use with care)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-deleted",
|
||||
action="store_true",
|
||||
help="Include source rows with is_deleted=1 (maps to del_flag=1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mapping-out",
|
||||
default="",
|
||||
help="Write legacy_id→new_id mapping JSON to this path (identity when IDs preserved)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
source_url = args.source_url or os.environ.get("DIBANG_SOURCE_DATABASE_URL", "")
|
||||
target_url = args.target_url or settings.DATABASE_URL
|
||||
|
||||
if not source_url:
|
||||
print("错误: 请通过 --source-url 或环境变量 DIBANG_SOURCE_DATABASE_URL 指定源地磅库连接", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.truncate_target and args.dry_run:
|
||||
print("错误: --truncate-target 不能与 --dry-run 同时使用", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source_engine = create_engine(source_url, pool_pre_ping=True)
|
||||
target_engine = create_engine(target_url, pool_pre_ping=True)
|
||||
|
||||
mapping_out = Path(args.mapping_out) if args.mapping_out else None
|
||||
|
||||
try:
|
||||
migrate(
|
||||
source_engine,
|
||||
target_engine,
|
||||
tenant_no=args.tenant_no,
|
||||
dry_run=args.dry_run,
|
||||
truncate_target=args.truncate_target,
|
||||
include_deleted=args.include_deleted,
|
||||
mapping_out=mapping_out,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"迁移失败: {exc}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,50 +0,0 @@
|
||||
-- 证件提醒独立为二级菜单(从车辆管理下移出,与 web-admin pl.ts 对齐)
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
'PL_GROUP_DOC', '证件提醒', 'PL_MENU_ROOT', 'M', 'document-reminder-module', NULL, NULL,
|
||||
1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
menu_name = VALUES(menu_name),
|
||||
parent_id = VALUES(parent_id),
|
||||
path = VALUES(path),
|
||||
order_num = VALUES(order_num),
|
||||
update_time = NOW();
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_DOC', order_num = 1, update_time = NOW()
|
||||
WHERE id = 'PL_DOC_REMINDER' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET order_num = 6, update_time = NOW()
|
||||
WHERE id = 'PL_GROUP_BASE' AND del_flag = 0;
|
||||
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT DISTINCT
|
||||
CONCAT('PLGRM_', SUBSTRING(MD5(CONCAT(rm.role_id, '-PL_GROUP_DOC')), 1, 24)),
|
||||
rm.role_id,
|
||||
'PL_GROUP_DOC',
|
||||
COALESCE(rm.tenant_no, 'shanxia2.0'),
|
||||
0,
|
||||
'pl document reminder group auto grant',
|
||||
'system',
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role_menu rm
|
||||
WHERE rm.menu_id = 'PL_DOC_REMINDER'
|
||||
AND rm.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_role_menu x
|
||||
WHERE x.role_id = rm.role_id
|
||||
AND x.menu_id = 'PL_GROUP_DOC'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,154 +0,0 @@
|
||||
-- 运输管理菜单二级分组(与 web-admin pl.ts 对齐)
|
||||
-- 1. 新增 6 个二级目录
|
||||
-- 2. 调整三级菜单 parent_id / 名称 / 排序
|
||||
-- 3. 为已有叶子权限的角色补全二级目录 role_menu
|
||||
|
||||
-- ── 1. 二级分组目录 ──
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES
|
||||
('PL_GROUP_PLAN', '运输计划', 'PL_MENU_ROOT', 'M', 'plan-module', NULL, NULL, 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_GROUP_CERT', '购买证上传', 'PL_MENU_ROOT', 'M', 'cert-upload', NULL, NULL, 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_GROUP_FREIGHT', '运输费用', 'PL_MENU_ROOT', 'M', 'freight-fee', NULL, NULL, 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_GROUP_VEHICLE', '车辆管理', 'PL_MENU_ROOT', 'M', 'vehicle-mgmt', NULL, NULL, 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_GROUP_DOC', '证件提醒', 'PL_MENU_ROOT', 'M', 'document-reminder-module', NULL, NULL, 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_GROUP_BASE', '基础信息库', 'PL_MENU_ROOT', 'M', 'base-info', NULL, NULL, 1, '1', '1', 'shanxia2.0', 6, NULL, 'system', '', NOW(), NULL, NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
menu_name = VALUES(menu_name),
|
||||
parent_id = VALUES(parent_id),
|
||||
path = VALUES(path),
|
||||
order_num = VALUES(order_num),
|
||||
update_time = NOW();
|
||||
|
||||
-- ── 2. 三级菜单挂到对应分组 ──
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_PLAN', order_num = 1, update_time = NOW()
|
||||
WHERE id = 'PL_PLAN' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_PLAN', order_num = 2, update_time = NOW()
|
||||
WHERE id = 'PL_PLAN_ARCHIVE' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_CERT', menu_name = '购买证上传', order_num = 1, update_time = NOW()
|
||||
WHERE id = 'PL_PLAN_CERT' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_FREIGHT', order_num = 1, update_time = NOW()
|
||||
WHERE id = 'PL_FREIGHT' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_FREIGHT', order_num = 2, update_time = NOW()
|
||||
WHERE id = 'PL_FREIGHT_APPROVAL' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_FREIGHT', order_num = 3, update_time = NOW()
|
||||
WHERE id = 'PL_FREIGHT_DISBURSE_MENU' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_VEHICLE', order_num = 1, update_time = NOW()
|
||||
WHERE id = 'PL_VEH_RECHARGE_APPLY' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_VEHICLE', order_num = 2, update_time = NOW()
|
||||
WHERE id = 'PL_VEH_RECHARGE_RECORD' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_VEHICLE', order_num = 3, update_time = NOW()
|
||||
WHERE id = 'PL_VEH_MAINT_APPLY' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_VEHICLE', order_num = 4, update_time = NOW()
|
||||
WHERE id = 'PL_VEH_MAINT_PROCESS' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_VEHICLE', order_num = 5, update_time = NOW()
|
||||
WHERE id = 'PL_VEH_UREA_APPLY' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_VEHICLE', order_num = 6, update_time = NOW()
|
||||
WHERE id = 'PL_VEH_UREA_DISBURSE' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_DOC', order_num = 1, update_time = NOW()
|
||||
WHERE id = 'PL_DOC_REMINDER' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET order_num = 6, update_time = NOW()
|
||||
WHERE id = 'PL_GROUP_BASE' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_BASE', menu_name = '承运商信息', order_num = 1, update_time = NOW()
|
||||
WHERE id = 'PL_CARRIER' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_BASE', menu_name = '全部车辆信息', order_num = 2, update_time = NOW()
|
||||
WHERE id = 'PL_VEHICLE' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_BASE', order_num = 3, update_time = NOW()
|
||||
WHERE id = 'PL_SELF_VEHICLE' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_BASE', menu_name = '驾押人员信息', order_num = 4, update_time = NOW()
|
||||
WHERE id = 'PL_DRIVER' AND del_flag = 0;
|
||||
|
||||
UPDATE sys_menu SET parent_id = 'PL_GROUP_BASE', menu_name = '运输地址库', order_num = 5, update_time = NOW()
|
||||
WHERE id = 'PL_TRANSPORT_ADDRESS' AND del_flag = 0;
|
||||
|
||||
-- ── 3. 角色已授权叶子菜单时,补全二级目录(及运输管理根) ──
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT DISTINCT
|
||||
CONCAT('PLGRM_', SUBSTRING(MD5(CONCAT(rm.role_id, '-', grp.id)), 1, 24)),
|
||||
rm.role_id,
|
||||
grp.id,
|
||||
COALESCE(rm.tenant_no, 'shanxia2.0'),
|
||||
0,
|
||||
'pl menu group auto grant',
|
||||
'system',
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role_menu rm
|
||||
INNER JOIN sys_menu leaf ON leaf.id = rm.menu_id AND leaf.del_flag = 0
|
||||
INNER JOIN sys_menu grp ON grp.id = leaf.parent_id AND grp.id LIKE 'PL_GROUP_%' AND grp.del_flag = 0
|
||||
WHERE rm.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_role_menu x
|
||||
WHERE x.role_id = rm.role_id
|
||||
AND x.menu_id = grp.id
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT DISTINCT
|
||||
CONCAT('PLGRR_', SUBSTRING(MD5(CONCAT(rm.role_id, '-PL_MENU_ROOT')), 1, 24)),
|
||||
rm.role_id,
|
||||
'PL_MENU_ROOT',
|
||||
COALESCE(rm.tenant_no, 'shanxia2.0'),
|
||||
0,
|
||||
'pl menu root auto grant',
|
||||
'system',
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role_menu rm
|
||||
INNER JOIN sys_menu m ON m.id = rm.menu_id AND m.del_flag = 0
|
||||
WHERE rm.del_flag = 0
|
||||
AND (
|
||||
m.id = 'PL_MENU_ROOT'
|
||||
OR m.parent_id = 'PL_MENU_ROOT'
|
||||
OR m.parent_id IN (
|
||||
'PL_GROUP_PLAN', 'PL_GROUP_CERT', 'PL_GROUP_FREIGHT', 'PL_GROUP_VEHICLE', 'PL_GROUP_DOC', 'PL_GROUP_BASE'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM sys_menu p
|
||||
WHERE p.id = m.parent_id
|
||||
AND p.parent_id IN (
|
||||
'PL_GROUP_PLAN', 'PL_GROUP_CERT', 'PL_GROUP_FREIGHT', 'PL_GROUP_VEHICLE', 'PL_GROUP_DOC', 'PL_GROUP_BASE'
|
||||
)
|
||||
AND p.del_flag = 0
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_role_menu x
|
||||
WHERE x.role_id = rm.role_id
|
||||
AND x.menu_id = 'PL_MENU_ROOT'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 运输计划:送(提)货单打印模板字段
|
||||
ALTER TABLE `pl_plan`
|
||||
ADD COLUMN `delivery_slip_template_content` longtext COMMENT '送(提)货单打印模板内容(富文本HTML)' AFTER `slip_template_content`;
|
||||
@@ -1,30 +0,0 @@
|
||||
-- 危化品证书按产品类型:新增合法使用证明字段,运输证 comment 更正,历史数据搬迁
|
||||
-- Spec: docs/superpowers/specs/2026-07-05-pl-hazard-cert-by-type-design.md
|
||||
|
||||
ALTER TABLE `pl_plan_product`
|
||||
ADD COLUMN `legal_use_cert_url` varchar(500) DEFAULT NULL COMMENT '合法使用证明URL' AFTER `transport_cert_url`;
|
||||
|
||||
ALTER TABLE `pl_plan_product`
|
||||
MODIFY COLUMN `transport_cert_url` varchar(500) DEFAULT NULL COMMENT '运输证URL';
|
||||
|
||||
-- 仅易制爆:旧 transport_cert_url 为合法使用证明
|
||||
UPDATE `pl_plan_product`
|
||||
SET `legal_use_cert_url` = `transport_cert_url`,
|
||||
`transport_cert_url` = NULL
|
||||
WHERE `is_toxic` = 0
|
||||
AND `is_explosive` = 1
|
||||
AND `transport_cert_url` IS NOT NULL
|
||||
AND `transport_cert_url` != ''
|
||||
AND `del_flag` = 0;
|
||||
|
||||
-- 双属性:旧 transport_cert_url 为合法使用证明,运输证待补传
|
||||
UPDATE `pl_plan_product`
|
||||
SET `legal_use_cert_url` = `transport_cert_url`,
|
||||
`transport_cert_url` = NULL
|
||||
WHERE `is_toxic` = 1
|
||||
AND `is_explosive` = 1
|
||||
AND `transport_cert_url` IS NOT NULL
|
||||
AND `transport_cert_url` != ''
|
||||
AND `del_flag` = 0;
|
||||
|
||||
-- 仅易制毒:transport_cert_url 原样保留(视为运输证),无 DML
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 运输计划:入库单打印模板字段
|
||||
ALTER TABLE `pl_plan`
|
||||
ADD COLUMN `slip_template_content` longtext COMMENT '入库单打印模板内容(富文本HTML)' AFTER `notify_message`;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 运输计划:分装单号字段
|
||||
ALTER TABLE `pl_plan`
|
||||
ADD COLUMN `subpack_slip_no` varchar(32) DEFAULT NULL COMMENT '分装单号 NO:SXFZ-YYYY-MM-DD-序号' AFTER `subpack_slip_template_content`;
|
||||
@@ -1,15 +0,0 @@
|
||||
-- 将历史分装单号统一为 NO:SXFZYYYYMMDD-###(可选一次性脚本)
|
||||
-- 示例:SXFZ20260705-001 / SXFZ-2026-07-05-001 -> NO:SXFZ20260705-001
|
||||
|
||||
UPDATE pl_plan
|
||||
SET subpack_slip_no = CONCAT(
|
||||
'NO:SXFZ',
|
||||
REPLACE(SUBSTRING_INDEX(SUBSTRING_INDEX(subpack_slip_no, '-', 4), '-', -3), '-', ''),
|
||||
'-',
|
||||
LPAD(SUBSTRING_INDEX(subpack_slip_no, '-', -1), 3, '0')
|
||||
)
|
||||
WHERE subpack_slip_no REGEXP '^(NO:)?SXFZ-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]+$';
|
||||
|
||||
UPDATE pl_plan
|
||||
SET subpack_slip_no = CONCAT('NO:', subpack_slip_no)
|
||||
WHERE subpack_slip_no REGEXP '^SXFZ[0-9]{8}-[0-9]+$';
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 运输计划:分装单打印模板字段
|
||||
ALTER TABLE `pl_plan`
|
||||
ADD COLUMN `subpack_slip_template_content` longtext COMMENT '分装单打印模板内容(富文本HTML)' AFTER `delivery_slip_template_content`;
|
||||
@@ -1,844 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将 sx 库报销/CRM 用户迁入 shanxia2.0 门户 sys_user。
|
||||
|
||||
规则(2026-07-12 确认):
|
||||
- 跳过 admin(门户已存在)
|
||||
- 不迁移地磅 users
|
||||
- 登录账号 user_no 使用中文姓名
|
||||
- 初始密码:每人随机 4 位数字,bcrypt 加密(与门户 get_password_hash 一致)
|
||||
- 支持 --dept-mapping-csv 按人指定部门
|
||||
- 可选同步角色到 sys_role / sys_user_role
|
||||
|
||||
用法:
|
||||
cd backend && source venv/bin/activate
|
||||
ENV_FILE=.env.production python scripts/migrate_users.py --dry-run --check-infra
|
||||
|
||||
ENV_FILE=.env.production python scripts/migrate_users.py \\
|
||||
--source-url mysql+pymysql://root:***@121.43.228.102:3306/sx \\
|
||||
--mapping-out ../docs/data-migration/mappings/user_mapping_result.json
|
||||
|
||||
环境变量:
|
||||
ENV_FILE 目标库配置(默认 backend/.env,生产用 .env.production)
|
||||
DATABASE_URL 目标库 shanxia2.0(由 Settings 读取)
|
||||
SX_SOURCE_DATABASE_URL 源库 sx(未传 --source-url 时优先使用)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.core.utils import generate_uuid
|
||||
|
||||
# 门户默认部门(销售部,见 migration_20260712_reset_sys_dept.sql)
|
||||
DEFAULT_DEPT_ID = "D65CAAE057914CCCAE2A81BE627A43EE"
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
ADMIN_USER_NO = "admin"
|
||||
|
||||
# 可选部门(供 --dept-mapping-csv 填写 dept_id 或 dept_name)
|
||||
DEPT_BY_ID = {
|
||||
"b13c2be2-4e71-11f1-9f47-00163e3bdd96": "安庆市山峡化工有限公司",
|
||||
"E65CAAE057914CCCAE2A81BE627A43EA": "销售部",
|
||||
"F65CAAE057914CCCAE2A81BE627A43EF": "财务部门",
|
||||
"C65CAAE057914CCCAE2A81BE627A43ED": "人力资源部",
|
||||
"D65CAAE057914CCCAE2A81BE627A43EE": "生产现场部",
|
||||
}
|
||||
DEPT_BY_NAME = {
|
||||
"安庆市山峡化工有限公司": "b13c2be2-4e71-11f1-9f47-00163e3bdd96",
|
||||
"销售部": "E65CAAE057914CCCAE2A81BE627A43EA",
|
||||
"销售部门": "E65CAAE057914CCCAE2A81BE627A43EA",
|
||||
"财务部门": "F65CAAE057914CCCAE2A81BE627A43EF",
|
||||
"财务部": "F65CAAE057914CCCAE2A81BE627A43EF",
|
||||
"人力资源部": "C65CAAE057914CCCAE2A81BE627A43ED",
|
||||
"人力资源部门": "C65CAAE057914CCCAE2A81BE627A43ED",
|
||||
"生产现场部": "D65CAAE057914CCCAE2A81BE627A43EE",
|
||||
}
|
||||
|
||||
|
||||
# 业务确认的手机号覆盖(中文名 -> 手机)
|
||||
PHONE_OVERRIDES: Dict[str, str] = {
|
||||
"吴九华": "18656008189",
|
||||
}
|
||||
|
||||
# bx 角色 -> 门户角色名
|
||||
BX_ROLE_MAP = {
|
||||
1: "管理员",
|
||||
2: "员工",
|
||||
3: "财务人员",
|
||||
4: "总经理",
|
||||
}
|
||||
|
||||
# CRM 角色 ID -> 门户角色名(sx.sql 生产快照)
|
||||
CRM_ROLE_MAP = {
|
||||
1975370859695071234: "销售人员",
|
||||
1979711425949528066: "销售主管",
|
||||
1984885915415953409: "行政",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceUser:
|
||||
user_no: str
|
||||
nick_name: str
|
||||
phone_no: str
|
||||
email: str
|
||||
sex: str
|
||||
bx_user_id: Optional[int] = None
|
||||
crm_user_id: Optional[int] = None
|
||||
bx_role_ids: Set[int] = field(default_factory=set)
|
||||
crm_role_ids: Set[int] = field(default_factory=set)
|
||||
sources: List[str] = field(default_factory=list)
|
||||
dept_id: str = DEFAULT_DEPT_ID
|
||||
|
||||
|
||||
@dataclass
|
||||
class MigrationStats:
|
||||
inserted_users: int = 0
|
||||
skipped_users: int = 0
|
||||
updated_mapping: int = 0
|
||||
roles_created: int = 0
|
||||
user_roles_inserted: int = 0
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _has_chinese(text_value: str) -> bool:
|
||||
return bool(re.search(r"[\u4e00-\u9fff]", text_value or ""))
|
||||
|
||||
|
||||
def _clip(value: Any, max_len: int) -> str:
|
||||
s = (value or "").strip() if value is not None else ""
|
||||
return s[:max_len]
|
||||
|
||||
|
||||
def _normalize_phone(value: Any) -> str:
|
||||
s = re.sub(r"\D", "", str(value or ""))
|
||||
return s[:11] if s else ""
|
||||
|
||||
|
||||
def generate_initial_password(used: Set[str]) -> str:
|
||||
"""生成不重复的 4 位数字初始密码(0000–9999)。"""
|
||||
for _ in range(200):
|
||||
plain = f"{secrets.randbelow(10000):04d}"
|
||||
if plain not in used:
|
||||
used.add(plain)
|
||||
return plain
|
||||
raise RuntimeError("无法生成唯一 4 位初始密码,请扩大位数或手动指定")
|
||||
|
||||
|
||||
def bx_role_labels(role_ids: Set[int]) -> str:
|
||||
return "、".join(BX_ROLE_MAP[r] for r in sorted(role_ids) if r in BX_ROLE_MAP)
|
||||
|
||||
|
||||
def crm_role_labels(role_ids: Set[int]) -> str:
|
||||
return "、".join(CRM_ROLE_MAP[r] for r in sorted(role_ids) if r in CRM_ROLE_MAP)
|
||||
|
||||
|
||||
def load_dept_mapping_csv(path: Path, default_dept_id: str) -> Dict[str, str]:
|
||||
"""读取 user_import_roster 填好的部门:user_no -> dept_id。"""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
mapping: Dict[str, str] = {}
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
user_no = (row.get("user_no") or "").strip()
|
||||
if not user_no:
|
||||
continue
|
||||
dept_id = (row.get("dept_id") or "").strip()
|
||||
dept_name = (row.get("dept_name") or "").strip()
|
||||
if not dept_id and dept_name:
|
||||
dept_id = DEPT_BY_NAME.get(dept_name, "")
|
||||
if dept_id:
|
||||
if dept_id not in DEPT_BY_ID:
|
||||
raise ValueError(f"用户「{user_no}」部门 ID 无效: {dept_id}")
|
||||
mapping[user_no] = dept_id
|
||||
else:
|
||||
mapping[user_no] = default_dept_id
|
||||
return mapping
|
||||
|
||||
|
||||
def derive_source_url(target_url: str, source_db: str = "sx") -> str:
|
||||
parsed = urlparse(target_url)
|
||||
# /shanxia2.0 -> /sx
|
||||
new_path = f"/{source_db}"
|
||||
return urlunparse(parsed._replace(path=new_path))
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
"""在 load_dotenv 之后实例化,确保读到 ENV_FILE / .env.production。"""
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.append(BACKEND_ROOT / ".env.production")
|
||||
candidates.append(BACKEND_ROOT / ".env")
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def check_mysql(engine: Engine, label: str) -> None:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
|
||||
|
||||
def check_redis(runtime_settings) -> Tuple[bool, str]:
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.Redis(
|
||||
host=runtime_settings.REDIS_HOST or "localhost",
|
||||
port=runtime_settings.REDIS_PORT,
|
||||
db=runtime_settings.REDIS_DB,
|
||||
password=runtime_settings.REDIS_PASSWORD or None,
|
||||
socket_connect_timeout=runtime_settings.REDIS_CONNECT_TIMEOUT_SEC,
|
||||
socket_timeout=runtime_settings.REDIS_OP_TIMEOUT_SEC,
|
||||
)
|
||||
client.ping()
|
||||
return True, f"{runtime_settings.REDIS_HOST}:{runtime_settings.REDIS_PORT}/{runtime_settings.REDIS_DB}"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def check_minio(runtime_settings) -> Tuple[bool, str]:
|
||||
try:
|
||||
from minio import Minio
|
||||
|
||||
if not runtime_settings.MINIO_ENDPOINT:
|
||||
return False, "MINIO_ENDPOINT 未配置"
|
||||
client = Minio(
|
||||
runtime_settings.MINIO_ENDPOINT,
|
||||
access_key=runtime_settings.MINIO_ACCESS_KEY,
|
||||
secret_key=runtime_settings.MINIO_SECRET_KEY,
|
||||
secure=runtime_settings.MINIO_SECURE,
|
||||
)
|
||||
client.bucket_exists(runtime_settings.MINIO_BUCKET)
|
||||
return True, f"{runtime_settings.MINIO_ENDPOINT} bucket={runtime_settings.MINIO_BUCKET}"
|
||||
except Exception as exc:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def fetch_bx_users(engine: Engine) -> List[dict]:
|
||||
sql = """
|
||||
SELECT id, username, password, real_name, phone, email, status, del_flag
|
||||
FROM bx_user
|
||||
WHERE del_flag = 0 AND username <> :admin
|
||||
ORDER BY id
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(sql), {"admin": ADMIN_USER_NO}).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def fetch_crm_users(engine: Engine) -> List[dict]:
|
||||
sql = """
|
||||
SELECT user_id, user_name, nick_name, phonenumber, email, password,
|
||||
sex, status, del_flag
|
||||
FROM sys_user
|
||||
WHERE del_flag = '0' AND status = '0' AND user_name <> :admin
|
||||
ORDER BY user_id
|
||||
"""
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(sql), {"admin": ADMIN_USER_NO}).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def fetch_bx_role_users(engine: Engine) -> Dict[int, Set[int]]:
|
||||
sql = "SELECT user_id, role_id FROM bx_role_user"
|
||||
out: Dict[int, Set[int]] = {}
|
||||
with engine.connect() as conn:
|
||||
for row in conn.execute(text(sql)).mappings():
|
||||
uid = int(row["user_id"])
|
||||
out.setdefault(uid, set()).add(int(row["role_id"]))
|
||||
return out
|
||||
|
||||
|
||||
def fetch_crm_user_roles(engine: Engine) -> Dict[int, Set[int]]:
|
||||
sql = "SELECT user_id, role_id FROM sys_user_role"
|
||||
out: Dict[int, Set[int]] = {}
|
||||
with engine.connect() as conn:
|
||||
for row in conn.execute(text(sql)).mappings():
|
||||
uid = int(row["user_id"])
|
||||
out.setdefault(uid, set()).add(int(row["role_id"]))
|
||||
return out
|
||||
|
||||
|
||||
def build_merged_users(
|
||||
source_engine: Engine,
|
||||
stats: MigrationStats,
|
||||
dept_mapping: Optional[Dict[str, str]] = None,
|
||||
default_dept_id: str = DEFAULT_DEPT_ID,
|
||||
) -> List[SourceUser]:
|
||||
bx_rows = fetch_bx_users(source_engine)
|
||||
crm_rows = fetch_crm_users(source_engine)
|
||||
bx_roles = fetch_bx_role_users(source_engine)
|
||||
crm_roles = fetch_crm_user_roles(source_engine)
|
||||
|
||||
merged: Dict[str, SourceUser] = {}
|
||||
|
||||
def upsert(key: str, **kwargs: Any) -> SourceUser:
|
||||
key = key.strip()
|
||||
if key not in merged:
|
||||
merged[key] = SourceUser(
|
||||
user_no=key,
|
||||
nick_name=key,
|
||||
phone_no="",
|
||||
email="",
|
||||
sex="0",
|
||||
)
|
||||
item = merged[key]
|
||||
for k, v in kwargs.items():
|
||||
if v is None:
|
||||
continue
|
||||
if k == "sources":
|
||||
values = v if isinstance(v, (list, tuple, set)) else [v]
|
||||
for s in values:
|
||||
if s and s not in item.sources:
|
||||
item.sources.append(s)
|
||||
elif k == "bx_role_ids" or k == "crm_role_ids":
|
||||
getattr(item, k).update(v)
|
||||
elif k in ("phone_no", "email") and getattr(item, k):
|
||||
continue
|
||||
else:
|
||||
setattr(item, k, v)
|
||||
return item
|
||||
|
||||
for row in bx_rows:
|
||||
name = (row.get("real_name") or "").strip()
|
||||
if not name:
|
||||
stats.warnings.append(f"bx_user id={row['id']} 缺少 real_name,已跳过")
|
||||
continue
|
||||
upsert(
|
||||
name,
|
||||
phone_no=_normalize_phone(row.get("phone")),
|
||||
email=_clip(row.get("email"), 50),
|
||||
bx_user_id=int(row["id"]),
|
||||
bx_role_ids=bx_roles.get(int(row["id"]), set()),
|
||||
sources="bx",
|
||||
)
|
||||
|
||||
for row in crm_rows:
|
||||
nick = (row.get("nick_name") or "").strip()
|
||||
uname = (row.get("user_name") or "").strip()
|
||||
name = nick if _has_chinese(nick) else (uname if _has_chinese(uname) else nick or uname)
|
||||
if not name or not _has_chinese(name):
|
||||
continue
|
||||
upsert(
|
||||
name,
|
||||
phone_no=_normalize_phone(row.get("phonenumber")),
|
||||
email=_clip(row.get("email"), 50),
|
||||
sex=str(row.get("sex") or "0"),
|
||||
crm_user_id=int(row["user_id"]),
|
||||
crm_role_ids=crm_roles.get(int(row["user_id"]), set()),
|
||||
sources="crm",
|
||||
)
|
||||
|
||||
# 手机号业务覆盖
|
||||
for name, phone in PHONE_OVERRIDES.items():
|
||||
if name in merged:
|
||||
merged[name].phone_no = phone
|
||||
|
||||
dept_mapping = dept_mapping or {}
|
||||
result = []
|
||||
for item in merged.values():
|
||||
item.dept_id = dept_mapping.get(item.user_no, default_dept_id)
|
||||
result.append(item)
|
||||
|
||||
result.sort(key=lambda u: u.user_no)
|
||||
return result
|
||||
|
||||
|
||||
def target_user_exists_conn(conn, user_no: str, tenant_no: str) -> Optional[str]:
|
||||
sql = """
|
||||
SELECT id FROM sys_user
|
||||
WHERE user_no = :user_no AND tenant_no = :tenant AND del_flag = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
row = conn.execute(text(sql), {"user_no": user_no, "tenant": tenant_no}).first()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
|
||||
def ensure_portal_role(
|
||||
conn,
|
||||
role_name: str,
|
||||
tenant_no: str,
|
||||
admin_id: str,
|
||||
dept_id: str,
|
||||
dry_run: bool,
|
||||
cache: Dict[str, str],
|
||||
stats: MigrationStats,
|
||||
) -> str:
|
||||
if role_name in cache:
|
||||
return cache[role_name]
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM sys_role
|
||||
WHERE role_name = :name AND tenant_no = :tenant AND del_flag = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"name": role_name, "tenant": tenant_no},
|
||||
).first()
|
||||
if row:
|
||||
cache[role_name] = str(row[0])
|
||||
return cache[role_name]
|
||||
|
||||
role_id = generate_uuid()
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_role (
|
||||
id, role_name, data_scope, status, tenant_no, order_num,
|
||||
remark, create_by, create_dept_id, create_time, update_by,
|
||||
update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :role_name, '5', '1', :tenant_no, 10,
|
||||
'用户迁移脚本自动创建', :create_by, :dept_id, :now, :create_by,
|
||||
:now, 0
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": role_id,
|
||||
"role_name": role_name,
|
||||
"tenant_no": tenant_no,
|
||||
"create_by": admin_id,
|
||||
"dept_id": dept_id,
|
||||
"now": datetime.now(),
|
||||
},
|
||||
)
|
||||
cache[role_name] = role_id
|
||||
stats.roles_created += 1
|
||||
return role_id
|
||||
|
||||
|
||||
def portal_role_names_for_user(user: SourceUser) -> Set[str]:
|
||||
names: Set[str] = set()
|
||||
for rid in user.bx_role_ids:
|
||||
name = BX_ROLE_MAP.get(rid)
|
||||
if name and name != "管理员":
|
||||
names.add(name)
|
||||
for rid in user.crm_role_ids:
|
||||
name = CRM_ROLE_MAP.get(rid)
|
||||
if name:
|
||||
names.add(name)
|
||||
if not names:
|
||||
names.add("员工")
|
||||
return names
|
||||
|
||||
|
||||
def insert_user_role(
|
||||
conn,
|
||||
user_id: str,
|
||||
role_id: str,
|
||||
tenant_no: str,
|
||||
admin_id: str,
|
||||
dept_id: str,
|
||||
dry_run: bool,
|
||||
stats: MigrationStats,
|
||||
) -> None:
|
||||
exists = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM sys_user_role
|
||||
WHERE user_id = :uid AND role_id = :rid AND tenant_no = :tenant AND del_flag = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"uid": user_id, "rid": role_id, "tenant": tenant_no},
|
||||
).first()
|
||||
if exists:
|
||||
return
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_user_role (
|
||||
id, user_id, role_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :user_id, :role_id, :tenant_no, 0, 'migrate_users',
|
||||
:create_by, :dept_id, :now, :create_by, :now, 0
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": generate_uuid(),
|
||||
"user_id": user_id,
|
||||
"role_id": role_id,
|
||||
"tenant_no": tenant_no,
|
||||
"create_by": admin_id,
|
||||
"dept_id": dept_id,
|
||||
"now": datetime.now(),
|
||||
},
|
||||
)
|
||||
stats.user_roles_inserted += 1
|
||||
|
||||
|
||||
def migrate_users(
|
||||
source_engine: Engine,
|
||||
target_engine: Engine,
|
||||
*,
|
||||
tenant_no: str,
|
||||
dept_id: str,
|
||||
dept_mapping: Optional[Dict[str, str]],
|
||||
dry_run: bool,
|
||||
with_roles: bool,
|
||||
stats: MigrationStats,
|
||||
) -> List[dict]:
|
||||
users = build_merged_users(
|
||||
source_engine, stats, dept_mapping=dept_mapping, default_dept_id=dept_id
|
||||
)
|
||||
mapping_rows: List[dict] = []
|
||||
used_passwords: Set[str] = set()
|
||||
|
||||
with target_engine.connect() as raw_conn:
|
||||
admin_row = raw_conn.execute(
|
||||
text("SELECT id FROM sys_user WHERE user_no = :u AND del_flag = 0 LIMIT 1"),
|
||||
{"u": ADMIN_USER_NO},
|
||||
).first()
|
||||
admin_id = str(admin_row[0]) if admin_row else ""
|
||||
|
||||
with target_engine.begin() as conn:
|
||||
role_cache: Dict[str, str] = {}
|
||||
|
||||
for user in users:
|
||||
existing_id = target_user_exists_conn(conn, user.user_no, tenant_no)
|
||||
plain_password = ""
|
||||
password_hash = ""
|
||||
if existing_id:
|
||||
stats.skipped_users += 1
|
||||
stats.warnings.append(f"已存在,跳过: {user.user_no} (id={existing_id})")
|
||||
new_id = existing_id
|
||||
else:
|
||||
plain_password = generate_initial_password(used_passwords)
|
||||
password_hash = get_password_hash(plain_password)
|
||||
new_id = generate_uuid()
|
||||
user_dept_id = user.dept_id or dept_id
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_user (
|
||||
id, dept_id, user_no, nick_name, email, phone_no, sex,
|
||||
avatar, password, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by,
|
||||
update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :dept_id, :user_no, :nick_name, :email, :phone_no, :sex,
|
||||
NULL, :password, '1', :tenant_no, 0, :remark,
|
||||
:create_by, :dept_id, :now, :create_by, :now, 0
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": new_id,
|
||||
"dept_id": user_dept_id,
|
||||
"user_no": _clip(user.user_no, 30),
|
||||
"nick_name": _clip(user.nick_name, 30),
|
||||
"email": _clip(user.email, 50),
|
||||
"phone_no": _clip(user.phone_no, 11),
|
||||
"sex": _clip(user.sex, 1) or "0",
|
||||
"password": password_hash,
|
||||
"tenant_no": tenant_no,
|
||||
"remark": f"migrate_users:{','.join(user.sources)}",
|
||||
"create_by": admin_id or None,
|
||||
"now": datetime.now(),
|
||||
},
|
||||
)
|
||||
stats.inserted_users += 1
|
||||
|
||||
user_dept_id = user.dept_id or dept_id
|
||||
if with_roles and admin_id:
|
||||
for role_name in portal_role_names_for_user(user):
|
||||
role_id = ensure_portal_role(
|
||||
conn,
|
||||
role_name,
|
||||
tenant_no,
|
||||
admin_id,
|
||||
user_dept_id,
|
||||
dry_run,
|
||||
role_cache,
|
||||
stats,
|
||||
)
|
||||
insert_user_role(
|
||||
conn,
|
||||
new_id,
|
||||
role_id,
|
||||
tenant_no,
|
||||
admin_id,
|
||||
user_dept_id,
|
||||
dry_run,
|
||||
stats,
|
||||
)
|
||||
|
||||
mapping_rows.append(
|
||||
{
|
||||
"new_user_id": new_id,
|
||||
"user_no": user.user_no,
|
||||
"nick_name": user.nick_name,
|
||||
"phone_no": user.phone_no,
|
||||
"dept_id": user_dept_id,
|
||||
"dept_name": DEPT_BY_ID.get(user_dept_id, ""),
|
||||
"bx_user_id": user.bx_user_id or "",
|
||||
"crm_user_id": user.crm_user_id or "",
|
||||
"dibang_user_id": "",
|
||||
"initial_password": plain_password,
|
||||
"password_hash_algo": "bcrypt",
|
||||
"status": "1",
|
||||
"sources": ",".join(user.sources),
|
||||
"bx_roles": bx_role_labels(user.bx_role_ids),
|
||||
"crm_roles": crm_role_labels(user.crm_role_ids),
|
||||
"portal_roles": ",".join(sorted(portal_role_names_for_user(user))),
|
||||
}
|
||||
)
|
||||
|
||||
return mapping_rows
|
||||
|
||||
|
||||
def write_mapping_outputs(
|
||||
mapping_rows: List[dict],
|
||||
json_path: Optional[Path],
|
||||
csv_path: Optional[Path],
|
||||
credentials_path: Optional[Path],
|
||||
) -> None:
|
||||
safe_rows = []
|
||||
for row in mapping_rows:
|
||||
item = dict(row)
|
||||
item.pop("initial_password", None)
|
||||
safe_rows.append(item)
|
||||
|
||||
if json_path:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"count": len(safe_rows),
|
||||
"users": safe_rows,
|
||||
"bx_id_to_user": {
|
||||
str(r["bx_user_id"]): r["new_user_id"]
|
||||
for r in safe_rows
|
||||
if r.get("bx_user_id")
|
||||
},
|
||||
"crm_id_to_user": {
|
||||
str(r["crm_user_id"]): r["new_user_id"]
|
||||
for r in safe_rows
|
||||
if r.get("crm_user_id")
|
||||
},
|
||||
}
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
if csv_path:
|
||||
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = [
|
||||
"new_user_id",
|
||||
"user_no",
|
||||
"nick_name",
|
||||
"phone_no",
|
||||
"dept_id",
|
||||
"dept_name",
|
||||
"bx_user_id",
|
||||
"crm_user_id",
|
||||
"dibang_user_id",
|
||||
"password_hash_algo",
|
||||
"status",
|
||||
"sources",
|
||||
"bx_roles",
|
||||
"crm_roles",
|
||||
"portal_roles",
|
||||
]
|
||||
with csv_path.open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(safe_rows)
|
||||
|
||||
if credentials_path:
|
||||
credentials_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cred_fields = [
|
||||
"user_no",
|
||||
"nick_name",
|
||||
"phone_no",
|
||||
"dept_name",
|
||||
"initial_password",
|
||||
"new_user_id",
|
||||
]
|
||||
with credentials_path.open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=cred_fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in mapping_rows:
|
||||
if row.get("initial_password"):
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="迁移 sx 报销/CRM 用户到 shanxia2.0")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只演练不写库")
|
||||
parser.add_argument("--env-file", default=".env.production", help="如 .env.production")
|
||||
parser.add_argument(
|
||||
"--source-url",
|
||||
default=os.getenv("SX_SOURCE_DATABASE_URL", ""),
|
||||
help="源库 sx 连接串",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-db",
|
||||
default="sx",
|
||||
help="从目标 DATABASE_URL 推导源库时的库名",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tenant-no",
|
||||
default=DEFAULT_TENANT_NO,
|
||||
help="目标租户编号",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dept-id",
|
||||
default=DEFAULT_DEPT_ID,
|
||||
help="默认部门 ID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dept-mapping-csv",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_import_roster.csv"),
|
||||
help="按人指定部门的 CSV(填 dept_id 或 dept_name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--credentials-out",
|
||||
default=str(
|
||||
BACKEND_ROOT.parent / "docs/data-migration/mappings/user_import_credentials.csv"
|
||||
),
|
||||
help="初始明文密码清单(仅新建用户,勿提交版本库)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mapping-out",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.json"),
|
||||
help="输出 JSON 映射",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mapping-csv",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/user_mapping_result.csv"),
|
||||
help="输出 CSV 映射",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-roles",
|
||||
action="store_true",
|
||||
help="不写入 sys_role / sys_user_role",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-infra",
|
||||
action="store_true",
|
||||
help="检查 MySQL/Redis/MinIO 连通性",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
load_settings_env(args.env_file or None)
|
||||
runtime_settings = load_runtime_settings()
|
||||
|
||||
target_url = runtime_settings.DATABASE_URL
|
||||
source_url = args.source_url or derive_source_url(target_url, args.source_db)
|
||||
|
||||
print("=== 用户迁移 migrate_users ===")
|
||||
print(f"模式: {'DRY-RUN' if args.dry_run else 'EXECUTE'}")
|
||||
print(f"源库: {source_url}")
|
||||
print(f"目标库: {target_url}")
|
||||
print(f"租户: {args.tenant_no}")
|
||||
print("跳过: admin、地磅账号")
|
||||
print()
|
||||
|
||||
source_engine = create_engine(source_url, pool_pre_ping=True)
|
||||
target_engine = create_engine(target_url, pool_pre_ping=True)
|
||||
|
||||
if args.check_infra:
|
||||
check_mysql(source_engine, "source")
|
||||
check_mysql(target_engine, "target")
|
||||
ok, msg = check_redis(runtime_settings)
|
||||
print(f"Redis: {'OK' if ok else 'FAIL'} — {msg}")
|
||||
ok, msg = check_minio(runtime_settings)
|
||||
print(f"MinIO: {'OK' if ok else 'FAIL'} — {msg}")
|
||||
print()
|
||||
|
||||
dept_mapping_path = Path(args.dept_mapping_csv)
|
||||
dept_mapping = load_dept_mapping_csv(dept_mapping_path, args.dept_id)
|
||||
if dept_mapping_path.is_file():
|
||||
filled = sum(
|
||||
1
|
||||
for k, v in dept_mapping.items()
|
||||
if v and v != args.dept_id
|
||||
)
|
||||
print(f"部门映射: {dept_mapping_path.name}({filled} 人已指定非默认部门,其余默认生产现场部)")
|
||||
else:
|
||||
print(f"部门映射: 未找到 {dept_mapping_path},全部使用默认部门")
|
||||
print("密码策略: 每人随机 4 位数字 + bcrypt(与门户一致)")
|
||||
print()
|
||||
|
||||
stats = MigrationStats()
|
||||
mapping_rows = migrate_users(
|
||||
source_engine,
|
||||
target_engine,
|
||||
tenant_no=args.tenant_no,
|
||||
dept_id=args.dept_id,
|
||||
dept_mapping=dept_mapping or None,
|
||||
dry_run=args.dry_run,
|
||||
with_roles=not args.no_roles,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
write_mapping_outputs(
|
||||
mapping_rows,
|
||||
Path(args.mapping_out),
|
||||
Path(args.mapping_csv),
|
||||
Path(args.credentials_out),
|
||||
)
|
||||
else:
|
||||
print("(dry-run 不写库;映射预览见下方统计,正式执行后写入 JSON/CSV)")
|
||||
|
||||
print(f"待迁移/处理用户数: {len(mapping_rows)}")
|
||||
print(f"新增用户: {stats.inserted_users}")
|
||||
print(f"跳过已存在: {stats.skipped_users}")
|
||||
print(f"新建角色: {stats.roles_created}")
|
||||
print(f"用户-角色关联: {stats.user_roles_inserted}")
|
||||
if stats.warnings:
|
||||
print("警告:")
|
||||
for w in stats.warnings:
|
||||
print(f" - {w}")
|
||||
if not args.dry_run:
|
||||
print(f"映射 JSON: {args.mapping_out}")
|
||||
print(f"映射 CSV: {args.mapping_csv}")
|
||||
print(f"密码清单: {args.credentials_out}")
|
||||
print("完成.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,143 +0,0 @@
|
||||
"""MinIO 附件 URL 规范化(数据迁移共用)。
|
||||
|
||||
背景
|
||||
----
|
||||
CRM / 地磅 / 报销与 shanxia2.0 共用同一 MinIO(bucket 默认 ``ruoyi``),
|
||||
物理对象无需复制。旧 CRM ``sys_oss.url`` 存的是带域名的绝对 URL(含历史
|
||||
``47.99.164.66:20004``、``121.43.228.102:9000`` 等),门户 ``sys_oss.url`` 与
|
||||
业务表字段存 **相对 object key**(如 ``2025/10/08/xxx.png``)。
|
||||
|
||||
旧表 ``sys_oss.file_name`` 通常已是 object key,与 ``url`` 中的路径一致,
|
||||
解析失败时可优先回退 ``file_name``。
|
||||
|
||||
报销 ``bx_attachment.file_path`` 本身即为相对路径(如 ``bx/2026-05-19/...``),
|
||||
可直接写入目标库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
|
||||
DEFAULT_BUCKET = "ruoyi"
|
||||
|
||||
# 历史绝对 URL 前缀(域名/网关变更);匹配时去掉前缀得到 object key
|
||||
LEGACY_URL_PREFIXES = (
|
||||
"http://47.99.164.66:20004/ruoyi/",
|
||||
"https://47.99.164.66:20004/ruoyi/",
|
||||
"http://121.43.228.102:9000/ruoyi/",
|
||||
"https://121.43.228.102:9000/ruoyi/",
|
||||
"http://www.chengyantek.cn:20004/ruoyi/",
|
||||
"https://www.chengyantek.cn:20004/ruoyi/",
|
||||
"http://www.chengyantek.cn/ruoyi/",
|
||||
"https://www.chengyantek.cn/ruoyi/",
|
||||
"http://www.chengyantek.cn/zkxw/",
|
||||
"https://www.chengyantek.cn/zkxw/",
|
||||
)
|
||||
|
||||
|
||||
def strip_url_query(raw: str) -> str:
|
||||
"""去掉预签名 query(``?X-Amz-...``)。"""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
return value.split("?", 1)[0].strip()
|
||||
|
||||
|
||||
def _strip_legacy_prefix(url: str) -> Optional[str]:
|
||||
normalized = strip_url_query(url)
|
||||
for prefix in LEGACY_URL_PREFIXES:
|
||||
if normalized.startswith(prefix):
|
||||
return normalized[len(prefix) :].lstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def extract_minio_object_key(
|
||||
raw_url: Optional[str],
|
||||
*,
|
||||
bucket: str = DEFAULT_BUCKET,
|
||||
file_name_fallback: Optional[str] = None,
|
||||
) -> str:
|
||||
"""将绝对/相对 URL 规范为 MinIO object key(不含 bucket)。"""
|
||||
url = strip_url_query(raw_url or "")
|
||||
if not url:
|
||||
fallback = (file_name_fallback or "").strip().lstrip("/")
|
||||
return fallback
|
||||
|
||||
legacy = _strip_legacy_prefix(url)
|
||||
if legacy:
|
||||
return legacy
|
||||
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
parsed = urlparse(url)
|
||||
path = unquote((parsed.path or "").lstrip("/"))
|
||||
bucket_prefix = f"{bucket}/"
|
||||
if path.startswith(bucket_prefix):
|
||||
return path[len(bucket_prefix) :]
|
||||
# 少数 URL 路径不含 bucket 段
|
||||
return path
|
||||
|
||||
return url.lstrip("/")
|
||||
|
||||
|
||||
def normalize_oss_storage_url(
|
||||
url: Optional[str],
|
||||
file_name: Optional[str] = None,
|
||||
*,
|
||||
bucket: str = DEFAULT_BUCKET,
|
||||
) -> str:
|
||||
"""迁移 CRM ``sys_oss`` 行:``url`` + ``file_name`` → 门户相对路径。"""
|
||||
from_url = extract_minio_object_key(url, bucket=bucket)
|
||||
if from_url:
|
||||
return from_url
|
||||
return (file_name or "").strip().lstrip("/")
|
||||
|
||||
|
||||
def join_relative_urls(urls: Iterable[str]) -> str:
|
||||
"""逗号拼接相对路径(去空、去重保序)。"""
|
||||
seen = set()
|
||||
parts: list[str] = []
|
||||
for item in urls:
|
||||
key = (item or "").strip().lstrip("/")
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
parts.append(key)
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def resolve_oss_id_csv(
|
||||
oss_id_csv: Optional[str],
|
||||
oss_id_to_relative_url: dict[str, str],
|
||||
) -> str:
|
||||
"""业务表 OSS ID 串 → 相对 URL 串(用于客户证照、跟进附件等)。"""
|
||||
if not oss_id_csv:
|
||||
return ""
|
||||
urls: list[str] = []
|
||||
for token in str(oss_id_csv).split(","):
|
||||
oss_id = token.strip()
|
||||
if not oss_id:
|
||||
continue
|
||||
mapped = oss_id_to_relative_url.get(oss_id)
|
||||
if mapped:
|
||||
urls.append(mapped)
|
||||
return join_relative_urls(urls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
samples = [
|
||||
(
|
||||
"http://47.99.164.66:20004/ruoyi/2025/10/08/5ac0966ab3a74eadab571ce0bb3b441a.png",
|
||||
"2025/10/08/5ac0966ab3a74eadab571ce0bb3b441a.png",
|
||||
),
|
||||
(
|
||||
"http://121.43.228.102:9000/ruoyi/2025/11/01/0048348b3d6c439f99db454c8c05e59a.gif?X-Amz-Algorithm=AWS4",
|
||||
"2025/11/01/0048348b3d6c439f99db454c8c05e59a.gif",
|
||||
),
|
||||
("2025/10/17/d601338282a44cf092bf68afe1aef239.jpeg", "2025/10/17/d601338282a44cf092bf68afe1aef239.jpeg"),
|
||||
("bx/2026-05-19/4ca4ffbeb7fc4e22913936d0860b2e2d.png", "bx/2026-05-19/4ca4ffbeb7fc4e22913936d0860b2e2d.png"),
|
||||
]
|
||||
for raw, expected in samples:
|
||||
got = extract_minio_object_key(raw)
|
||||
assert got == expected, f"{raw!r} -> {got!r}, want {expected!r}"
|
||||
print("minio_url self-check ok")
|
||||
@@ -1,7 +0,0 @@
|
||||
-- 隐藏车辆尿素申请、车辆尿素发放登记(侧栏不展示,路由与权限保留)
|
||||
UPDATE sys_menu
|
||||
SET visible = '0',
|
||||
remark = '侧栏不展示',
|
||||
update_time = NOW()
|
||||
WHERE id IN ('PL_VEH_UREA_APPLY', 'PL_VEH_UREA_DISBURSE')
|
||||
AND del_flag = 0;
|
||||
@@ -1,63 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 新增财务部门(shanxia2.0 租户)
|
||||
-- 执行库:shanxia2.0
|
||||
-- 日期:2026-07-12
|
||||
--
|
||||
-- 部门树(执行后):
|
||||
-- 安庆市山峡化工有限公司(根)
|
||||
-- ├── 销售部
|
||||
-- ├── 财务部门 ← 本脚本新增
|
||||
-- ├── 人力资源部
|
||||
-- └── 生产现场部
|
||||
--
|
||||
-- 可重复执行:已存在同 ID 部门则跳过插入
|
||||
-- =============================================================================
|
||||
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET collation_connection = 'utf8mb4_unicode_ci';
|
||||
|
||||
SET @tenant_no := 'shanxia2.0';
|
||||
SET @admin_user_id := 'd3000000000000000000000000000001';
|
||||
SET @dept_root_id := 'b13c2be2-4e71-11f1-9f47-00163e3bdd96';
|
||||
SET @dept_finance_id := 'F65CAAE057914CCCAE2A81BE627A43EF';
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
INSERT INTO sys_dept (
|
||||
id, parent_id, dept_name, dept_category_code, status,
|
||||
leader, leader_phone, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
@dept_finance_id,
|
||||
@dept_root_id,
|
||||
'财务部门',
|
||||
'bm',
|
||||
'1',
|
||||
NULL,
|
||||
NULL,
|
||||
@tenant_no,
|
||||
15,
|
||||
'用户迁移前新增',
|
||||
@admin_user_id,
|
||||
@dept_root_id,
|
||||
NOW(),
|
||||
@admin_user_id,
|
||||
NOW(),
|
||||
0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_dept
|
||||
WHERE id = @dept_finance_id COLLATE utf8mb4_unicode_ci
|
||||
AND tenant_no = @tenant_no COLLATE utf8mb4_unicode_ci
|
||||
AND del_flag = 0
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
SELECT id, parent_id, dept_name, dept_category_code, order_num, status
|
||||
FROM sys_dept
|
||||
WHERE tenant_no = @tenant_no COLLATE utf8mb4_unicode_ci
|
||||
AND del_flag = 0
|
||||
ORDER BY parent_id, order_num, dept_name;
|
||||
@@ -1,24 +0,0 @@
|
||||
-- pl_personnel 增加人员归属:self-山峡 carrier-非山峡
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'pl_personnel'
|
||||
AND COLUMN_NAME = 'personnel_affiliation'
|
||||
);
|
||||
|
||||
SET @ddl := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `pl_personnel`
|
||||
ADD COLUMN `personnel_affiliation` varchar(20) NOT NULL DEFAULT ''self''
|
||||
COMMENT ''人员归属 self-山峡 carrier-非山峡''
|
||||
AFTER `personnel_type`',
|
||||
'SELECT ''personnel_affiliation already exists'' AS msg'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE `pl_personnel`
|
||||
SET `personnel_affiliation` = 'self'
|
||||
WHERE `personnel_affiliation` IS NULL OR `personnel_affiliation` = '';
|
||||
@@ -1,52 +0,0 @@
|
||||
-- pl_personnel 增加人员类型;历史数据按车辆关联推断
|
||||
-- 可重复执行(列已存在则跳过加列,UPDATE 可重复跑)
|
||||
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'pl_personnel'
|
||||
AND COLUMN_NAME = 'personnel_type'
|
||||
);
|
||||
|
||||
SET @ddl := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `pl_personnel`
|
||||
ADD COLUMN `personnel_type` varchar(20) NOT NULL DEFAULT ''both''
|
||||
COMMENT ''人员类型 driver-驾驶员 escort-押运员 both-驾驶员兼押运员''
|
||||
AFTER `id_card_no`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 仅有 driver 关联
|
||||
UPDATE pl_personnel p
|
||||
SET p.personnel_type = 'driver'
|
||||
WHERE p.del_flag = 0
|
||||
AND (p.personnel_type IS NULL OR p.personnel_type = '' OR p.personnel_type = 'both')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pl_vehicle_personnel vp
|
||||
WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'driver'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pl_vehicle_personnel vp
|
||||
WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'escort'
|
||||
);
|
||||
|
||||
-- 仅有 escort 关联
|
||||
UPDATE pl_personnel p
|
||||
SET p.personnel_type = 'escort'
|
||||
WHERE p.del_flag = 0
|
||||
AND (p.personnel_type IS NULL OR p.personnel_type = '' OR p.personnel_type = 'both')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pl_vehicle_personnel vp
|
||||
WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'escort'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pl_vehicle_personnel vp
|
||||
WHERE vp.personnel_id = p.id AND vp.del_flag = 0 AND vp.role = 'driver'
|
||||
);
|
||||
|
||||
-- 同时有 driver + escort 或无关联保持 both(默认)
|
||||
@@ -1,117 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 重置 shanxia2.0 租户部门树(P0 数据迁移前置)
|
||||
-- 执行库:shanxia2.0
|
||||
-- 日期:2026-07-12
|
||||
--
|
||||
-- 目标结构:
|
||||
-- 安庆市山峡化工有限公司(根,组织类别 gs)
|
||||
-- ├── 销售部(bm)
|
||||
-- ├── 人力资源部(bm)
|
||||
-- └── 生产现场部(bm)
|
||||
--
|
||||
-- 用法(生产):
|
||||
-- mysql -h121.43.228.102 -uroot -p shanxia2.0 < backend/scripts/migration_20260712_reset_sys_dept.sql
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 物理删除租户 shanxia2.0 下全部 sys_dept 后重建(含已软删记录)
|
||||
-- 2. 复用根部门 ID(admin.dept_id 已指向)与销售部 ID(用户迁移脚本默认部门)
|
||||
-- 3. 同步将 admin 挂到根公司;清理已失效的自定义数据权限部门引用
|
||||
-- =============================================================================
|
||||
|
||||
-- 与表字段 collation 对齐,避免 1267 Illegal mix of collations
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET collation_connection = 'utf8mb4_unicode_ci';
|
||||
|
||||
SET @tenant_no := 'shanxia2.0';
|
||||
SET @admin_user_id := 'd3000000000000000000000000000001';
|
||||
|
||||
-- 稳定部门 ID(与历史/迁移脚本对齐)
|
||||
SET @dept_root_id := 'b13c2be2-4e71-11f1-9f47-00163e3bdd96';
|
||||
SET @dept_sales_id := 'E65CAAE057914CCCAE2A81BE627A43EA';
|
||||
SET @dept_hr_id := 'C65CAAE057914CCCAE2A81BE627A43ED';
|
||||
SET @dept_prod_id := 'D65CAAE057914CCCAE2A81BE627A43EE';
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 清理自定义数据权限中指向旧部门的配置(当前库通常为空,预留)
|
||||
DELETE FROM sys_role_data_scope_detail
|
||||
WHERE tenant_no = @tenant_no COLLATE utf8mb4_unicode_ci
|
||||
AND scope_type = 'dept' COLLATE utf8mb4_unicode_ci
|
||||
AND scope_value COLLATE utf8mb4_unicode_ci NOT IN (
|
||||
@dept_root_id COLLATE utf8mb4_unicode_ci,
|
||||
@dept_sales_id COLLATE utf8mb4_unicode_ci,
|
||||
@dept_hr_id COLLATE utf8mb4_unicode_ci,
|
||||
@dept_prod_id COLLATE utf8mb4_unicode_ci
|
||||
);
|
||||
|
||||
-- 清空租户全部部门(物理删除)
|
||||
DELETE FROM sys_dept
|
||||
WHERE tenant_no = @tenant_no COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 根:安庆市山峡化工有限公司
|
||||
INSERT INTO sys_dept (
|
||||
id, parent_id, dept_name, dept_category_code, status,
|
||||
leader, leader_phone, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
@dept_root_id, '0', '安庆市山峡化工有限公司', 'gs', '1',
|
||||
NULL, NULL, @tenant_no, 0, '数据迁移脚本重建',
|
||||
@admin_user_id, '', NOW(), @admin_user_id, NOW(), 0
|
||||
);
|
||||
|
||||
-- 一级部门:销售部
|
||||
INSERT INTO sys_dept (
|
||||
id, parent_id, dept_name, dept_category_code, status,
|
||||
leader, leader_phone, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
@dept_sales_id, @dept_root_id, '销售部', 'bm', '1',
|
||||
NULL, NULL, @tenant_no, 10, '数据迁移脚本重建',
|
||||
@admin_user_id, @dept_root_id, NOW(), @admin_user_id, NOW(), 0
|
||||
);
|
||||
|
||||
-- 一级部门:人力资源部
|
||||
INSERT INTO sys_dept (
|
||||
id, parent_id, dept_name, dept_category_code, status,
|
||||
leader, leader_phone, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
@dept_hr_id, @dept_root_id, '人力资源部', 'bm', '1',
|
||||
NULL, NULL, @tenant_no, 20, '数据迁移脚本重建',
|
||||
@admin_user_id, @dept_root_id, NOW(), @admin_user_id, NOW(), 0
|
||||
);
|
||||
|
||||
-- 一级部门:生产现场部
|
||||
INSERT INTO sys_dept (
|
||||
id, parent_id, dept_name, dept_category_code, status,
|
||||
leader, leader_phone, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
@dept_prod_id, @dept_root_id, '生产现场部', 'bm', '1',
|
||||
NULL, NULL, @tenant_no, 30, '数据迁移脚本重建',
|
||||
@admin_user_id, @dept_root_id, NOW(), @admin_user_id, NOW(), 0
|
||||
);
|
||||
|
||||
-- admin 挂到根公司(与重建后 ID 一致,幂等)
|
||||
UPDATE sys_user
|
||||
SET dept_id = @dept_root_id,
|
||||
update_by = @admin_user_id,
|
||||
update_time = NOW()
|
||||
WHERE id = @admin_user_id COLLATE utf8mb4_unicode_ci
|
||||
AND tenant_no = @tenant_no COLLATE utf8mb4_unicode_ci
|
||||
AND del_flag = 0;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 验收查询
|
||||
SELECT id, parent_id, dept_name, dept_category_code, order_num, status
|
||||
FROM sys_dept
|
||||
WHERE tenant_no = @tenant_no COLLATE utf8mb4_unicode_ci
|
||||
AND del_flag = 0
|
||||
ORDER BY parent_id, order_num, dept_name;
|
||||
|
||||
SELECT id, user_no, nick_name, dept_id
|
||||
FROM sys_user
|
||||
WHERE tenant_no = @tenant_no COLLATE utf8mb4_unicode_ci
|
||||
AND del_flag = 0
|
||||
ORDER BY user_no;
|
||||
@@ -1,85 +0,0 @@
|
||||
-- 人员从业资格证拆分:cert_name 重命名(可重复执行)
|
||||
-- 前置 spec: docs/superpowers/specs/2026-07-13-pl-personnel-cert-split-design.md
|
||||
|
||||
-- 1) 押运员证 → 押运员从业资格证(全部 personnel 证书)
|
||||
UPDATE pl_cert c
|
||||
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
|
||||
SET c.cert_name = '押运员从业资格证',
|
||||
c.update_time = NOW()
|
||||
WHERE c.del_flag = 0
|
||||
AND c.owner_type = 'personnel'
|
||||
AND c.cert_name = '押运员证';
|
||||
|
||||
-- 2) 泛称从业资格证 → 驾驶员从业资格证(driver / both)
|
||||
UPDATE pl_cert c
|
||||
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
|
||||
SET c.cert_name = '驾驶员从业资格证',
|
||||
c.update_time = NOW()
|
||||
WHERE c.del_flag = 0
|
||||
AND c.owner_type = 'personnel'
|
||||
AND c.cert_name = '从业资格证'
|
||||
AND p.personnel_type IN ('driver', 'both');
|
||||
|
||||
-- 3) 泛称从业资格证 → 押运员从业资格证(escort)
|
||||
UPDATE pl_cert c
|
||||
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
|
||||
SET c.cert_name = '押运员从业资格证',
|
||||
c.update_time = NOW()
|
||||
WHERE c.del_flag = 0
|
||||
AND c.owner_type = 'personnel'
|
||||
AND c.cert_name = '从业资格证'
|
||||
AND p.personnel_type = 'escort';
|
||||
|
||||
-- 4) both 人员:若迁移后仍缺驾/押任一从业证,供运营补录(验证查询,不修改数据)
|
||||
SELECT
|
||||
p.id AS personnel_id,
|
||||
p.personnel_name,
|
||||
p.personnel_type,
|
||||
GROUP_CONCAT(c.cert_name ORDER BY c.cert_name SEPARATOR '、') AS cert_names
|
||||
FROM pl_personnel p
|
||||
LEFT JOIN pl_cert c
|
||||
ON c.owner_id = p.id
|
||||
AND c.owner_type = 'personnel'
|
||||
AND c.del_flag = 0
|
||||
WHERE p.del_flag = 0
|
||||
AND p.personnel_type = 'both'
|
||||
GROUP BY p.id, p.personnel_name, p.personnel_type
|
||||
HAVING SUM(CASE WHEN c.cert_name IN ('驾驶员从业资格证') THEN 1 ELSE 0 END) = 0
|
||||
OR SUM(CASE WHEN c.cert_name IN ('押运员从业资格证', '押运员证') THEN 1 ELSE 0 END) = 0;
|
||||
|
||||
-- 5) 危化品从业资格证:不自动迁移(人工清单)
|
||||
SELECT
|
||||
p.id AS personnel_id,
|
||||
p.personnel_name,
|
||||
p.personnel_type,
|
||||
c.id AS cert_id,
|
||||
c.cert_name
|
||||
FROM pl_cert c
|
||||
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
|
||||
WHERE c.del_flag = 0
|
||||
AND c.owner_type = 'personnel'
|
||||
AND c.cert_name = '危化品从业资格证';
|
||||
|
||||
-- 6) both + 仍保留泛称「从业资格证」的歧义记录(需人工处理,不自动改)
|
||||
SELECT
|
||||
p.id AS personnel_id,
|
||||
p.personnel_name,
|
||||
c.id AS cert_id,
|
||||
c.cert_name
|
||||
FROM pl_cert c
|
||||
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
|
||||
WHERE c.del_flag = 0
|
||||
AND c.owner_type = 'personnel'
|
||||
AND c.cert_name = '从业资格证'
|
||||
AND p.personnel_type = 'both';
|
||||
|
||||
-- 7) 字典 pl_cert_name 增量(可选,可重复执行)
|
||||
INSERT INTO sys_dict_data (id, dict_label, dict_value, tag_type, dict_type, tenant_no, order_num, del_flag, create_time, update_time)
|
||||
SELECT 'PL_CERT_DRIVER_QUAL', '驾驶员从业资格证', '驾驶员从业资格证', 'primary', 'pl_cert_name', 'shanxia2.0', 6, 0, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE id = 'PL_CERT_DRIVER_QUAL');
|
||||
|
||||
INSERT INTO sys_dict_data (id, dict_label, dict_value, tag_type, dict_type, tenant_no, order_num, del_flag, create_time, update_time)
|
||||
SELECT 'PL_CERT_ESCORT_QUAL', '押运员从业资格证', '押运员从业资格证', 'warning', 'pl_cert_name', 'shanxia2.0', 7, 0, NOW(), NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE id = 'PL_CERT_ESCORT_QUAL');
|
||||
@@ -1,20 +0,0 @@
|
||||
-- OCR 月度调用计数(阿里云免费额度约 170 次/月)
|
||||
-- 可重复执行:表已存在则跳过
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pl_ocr_usage_month` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`usage_month` varchar(7) NOT NULL COMMENT '自然月 YYYY-MM',
|
||||
`used_count` int(11) NOT NULL DEFAULT '0' COMMENT '当月已调用次数',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号(配额按账号全局,不按租户拆分)',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_usage_month` (`usage_month`),
|
||||
KEY `idx_tenant` (`tenant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='阿里云 OCR 月度调用计数';
|
||||
@@ -1,208 +0,0 @@
|
||||
-- ============================================================
|
||||
-- CRM 调价函管理:建表 + 字典 + 菜单(合同管理下方)
|
||||
-- 执行前可先备份 sys_menu;可重复执行(菜单/字典做了 NOT EXISTS 防护)
|
||||
-- ============================================================
|
||||
|
||||
-- 1) 业务表
|
||||
CREATE TABLE IF NOT EXISTS `crm_price_letter` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||||
`letter_type` varchar(20) NOT NULL COMMENT '类型 receive接受/send发出',
|
||||
`adjust_date` date NOT NULL COMMENT '调价日期',
|
||||
`customer_id` varchar(64) NOT NULL COMMENT '客户ID(crm_customer.id)',
|
||||
`customer_name` varchar(200) DEFAULT NULL COMMENT '客户名称(冗余)',
|
||||
`product_id` varchar(64) NOT NULL COMMENT '产品ID(crm_product.id)',
|
||||
`product_name` varchar(200) DEFAULT NULL COMMENT '产品名称(冗余)',
|
||||
`price` decimal(15,2) NOT NULL COMMENT '调整后现金价格(元)',
|
||||
`bill_price` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '调整后汇票价格(元)',
|
||||
`content` mediumtext COMMENT '发出类富文本HTML;接受类为空',
|
||||
`attachment_urls` varchar(4000) DEFAULT NULL COMMENT '附件URL(逗号分隔,兼容上传组件)',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'normal' COMMENT '状态 normal正常/invalid已作废',
|
||||
`invalid_reason` varchar(200) DEFAULT NULL COMMENT '作废原因',
|
||||
`invalid_time` datetime DEFAULT NULL COMMENT '作废时间',
|
||||
`invalid_by` varchar(64) DEFAULT NULL COMMENT '作废人ID',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人ID',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人ID',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记(0存在 1删除)',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_price_letter_tenant` (`tenant_no`) USING BTREE,
|
||||
KEY `idx_price_letter_type` (`letter_type`) USING BTREE,
|
||||
KEY `idx_price_letter_status` (`status`) USING BTREE,
|
||||
KEY `idx_price_letter_customer` (`customer_id`) USING BTREE,
|
||||
KEY `idx_price_letter_product` (`product_id`) USING BTREE,
|
||||
KEY `idx_price_letter_adjust_date` (`adjust_date`) USING BTREE,
|
||||
KEY `idx_price_letter_create_time` (`create_time`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-调价函档案(独立,不关联合同/报价单)';
|
||||
|
||||
-- 2) 字典类型
|
||||
INSERT INTO `sys_dict_type` (
|
||||
`id`, `dict_name`, `dict_type`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
'CRM_DTYPE_PRICE_LETTER_TYPE', '调价函类型', 'crm_price_letter_type', 'shanxia2.0', 90, '接受/发出',
|
||||
'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_type` WHERE `id` = 'CRM_DTYPE_PRICE_LETTER_TYPE' OR `dict_type` = 'crm_price_letter_type');
|
||||
|
||||
INSERT INTO `sys_dict_type` (
|
||||
`id`, `dict_name`, `dict_type`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
'CRM_DTYPE_PRICE_LETTER_STATUS', '调价函状态', 'crm_price_letter_status', 'shanxia2.0', 91, '正常/已作废',
|
||||
'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_type` WHERE `id` = 'CRM_DTYPE_PRICE_LETTER_STATUS' OR `dict_type` = 'crm_price_letter_status');
|
||||
|
||||
-- 3) 字典数据
|
||||
INSERT INTO `sys_dict_data` (
|
||||
`id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
|
||||
`remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM_DDATA_PRICE_LETTER_TYPE_01', '接受调价函', 'receive', 'warning', 'crm_price_letter_type', 'shanxia2.0', 1,
|
||||
NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_TYPE_01');
|
||||
|
||||
INSERT INTO `sys_dict_data` (
|
||||
`id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
|
||||
`remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM_DDATA_PRICE_LETTER_TYPE_02', '发出调价函', 'send', 'success', 'crm_price_letter_type', 'shanxia2.0', 2,
|
||||
NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_TYPE_02');
|
||||
|
||||
INSERT INTO `sys_dict_data` (
|
||||
`id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
|
||||
`remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM_DDATA_PRICE_LETTER_STATUS_01', '正常', 'normal', 'success', 'crm_price_letter_status', 'shanxia2.0', 1,
|
||||
NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_STATUS_01');
|
||||
|
||||
INSERT INTO `sys_dict_data` (
|
||||
`id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`,
|
||||
`remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM_DDATA_PRICE_LETTER_STATUS_02', '已作废', 'invalid', 'danger', 'crm_price_letter_status', 'shanxia2.0', 2,
|
||||
NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_dict_data` WHERE `id` = 'CRM_DDATA_PRICE_LETTER_STATUS_02');
|
||||
|
||||
-- 4) 菜单排序:合同管理(order_num=6) 之后顺延
|
||||
UPDATE `sys_menu`
|
||||
SET `order_num` = `order_num` + 1,
|
||||
`update_time` = NOW()
|
||||
WHERE `parent_id` = 'CRM00000000000000000000000000001'
|
||||
AND `order_num` >= 7
|
||||
AND `del_flag` = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT id FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000030' AND `del_flag` = 0
|
||||
) t
|
||||
);
|
||||
|
||||
-- 5) 菜单 + 按钮(无 remove,含 print/void)
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
'CRM3000000000000000000000000030', '调价函管理', 'CRM00000000000000000000000000001', 'C',
|
||||
'price-letter', '/crm/price-letter/index', 'crm:priceLetter:list',
|
||||
1, '1', '1', 'shanxia2.0', 7, '独立调价函档案:接受(附件)/发出(富文本+可选附件);不关联合同与报价单',
|
||||
'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000030');
|
||||
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM3000000000000000000000000031', '调价函新增', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:add',
|
||||
1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000031');
|
||||
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM3000000000000000000000000032', '调价函编辑', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:edit',
|
||||
1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000032');
|
||||
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM3000000000000000000000000034', '调价函查看', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:query',
|
||||
1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000034');
|
||||
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM3000000000000000000000000035', '调价函打印', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:print',
|
||||
1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000035');
|
||||
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT 'CRM3000000000000000000000000036', '调价函作废', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:void',
|
||||
1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'CRM3000000000000000000000000036');
|
||||
|
||||
-- 6) 若曾误写「删除」按钮,统一软删(仅作废,无物理删除能力)
|
||||
UPDATE `sys_menu`
|
||||
SET `del_flag` = 1,
|
||||
`update_time` = NOW(),
|
||||
`remark` = CONCAT(IFNULL(`remark`, ''), ' [disabled: use void]')
|
||||
WHERE `id` = 'CRM3000000000000000000000000033'
|
||||
AND `del_flag` = 0;
|
||||
|
||||
-- 7) 将调价函菜单/按钮授权给已拥有「合同管理」的角色(可重复执行)
|
||||
INSERT INTO `sys_role_menu` (
|
||||
`id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT DISTINCT
|
||||
CONCAT('CRM_RM_PL_', SUBSTRING(MD5(CONCAT(rm.role_id, '-', m.id)), 1, 20)),
|
||||
rm.role_id,
|
||||
m.id,
|
||||
COALESCE(NULLIF(rm.tenant_no, ''), 'shanxia2.0'),
|
||||
0,
|
||||
'auto grant price-letter with contract',
|
||||
'system',
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM `sys_role_menu` rm
|
||||
JOIN `sys_menu` m ON m.id IN (
|
||||
'CRM3000000000000000000000000030',
|
||||
'CRM3000000000000000000000000031',
|
||||
'CRM3000000000000000000000000032',
|
||||
'CRM3000000000000000000000000034',
|
||||
'CRM3000000000000000000000000035',
|
||||
'CRM3000000000000000000000000036'
|
||||
) AND m.del_flag = 0
|
||||
WHERE rm.menu_id = 'CRM3000000000000000000000000020'
|
||||
AND rm.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM `sys_role_menu` x
|
||||
WHERE x.role_id = rm.role_id AND x.menu_id = m.id AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
-- 调价函:price 语义改为现金价;新增汇票价 bill_price
|
||||
-- 可重复执行
|
||||
|
||||
ALTER TABLE `crm_price_letter`
|
||||
MODIFY COLUMN `price` decimal(15,2) NOT NULL COMMENT '调整后现金价格(元)';
|
||||
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'crm_price_letter'
|
||||
AND COLUMN_NAME = 'bill_price'
|
||||
);
|
||||
|
||||
SET @sql := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `crm_price_letter` ADD COLUMN `bill_price` decimal(15,2) NOT NULL DEFAULT 0.00 COMMENT ''调整后汇票价格(元)'' AFTER `price`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 历史数据:汇票价未填时用现金价回填,避免 NOT NULL 后业务误判为 0
|
||||
UPDATE `crm_price_letter`
|
||||
SET `bill_price` = `price`
|
||||
WHERE (`bill_price` IS NULL OR `bill_price` = 0)
|
||||
AND `price` IS NOT NULL
|
||||
AND `del_flag` = 0;
|
||||
@@ -1,182 +0,0 @@
|
||||
-- PL 入库单 / 出库单(主从表)+ 菜单「运输管理 → 库存管理 → 入库单/出库单」
|
||||
-- Spec: docs/superpowers/specs/2026-07-19-pl-inbound-outbound-order-design.md
|
||||
|
||||
-- ── 1. 入库主表 ──
|
||||
CREATE TABLE IF NOT EXISTS `pl_inbound_order` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`order_no` varchar(32) NOT NULL COMMENT '入库单号',
|
||||
`plan_id` varchar(64) NOT NULL COMMENT '运输计划ID',
|
||||
`transport_id` varchar(64) NOT NULL COMMENT '运输单ID',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'in_progress' COMMENT 'in_progress/completed/voided',
|
||||
`partner_company` varchar(200) DEFAULT NULL COMMENT '往来单位快照',
|
||||
`vehicle_no` varchar(32) DEFAULT NULL COMMENT '车牌快照',
|
||||
`plan_no` varchar(64) DEFAULT NULL COMMENT '计划号快照',
|
||||
`plan_date` date DEFAULT NULL COMMENT '计划日期快照',
|
||||
`site_access_token` varchar(64) NOT NULL COMMENT '现场免登录令牌',
|
||||
`site_feedback_remark` varchar(500) DEFAULT NULL COMMENT '现场备注',
|
||||
`site_feedback_images` text COMMENT '现场附件URLs(JSON数组)',
|
||||
`site_feedback_time` datetime DEFAULT NULL COMMENT '最近现场提交时间',
|
||||
`site_first_submitted_at` datetime DEFAULT NULL COMMENT '首次现场提交时间',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_pl_inbound_order_no` (`tenant_no`,`order_no`),
|
||||
UNIQUE KEY `uk_pl_inbound_site_token` (`site_access_token`),
|
||||
KEY `idx_pl_inbound_plan` (`tenant_no`,`plan_id`),
|
||||
KEY `idx_pl_inbound_transport` (`tenant_no`,`transport_id`),
|
||||
KEY `idx_pl_inbound_status` (`tenant_no`,`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输计划-入库单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pl_inbound_order_item` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`order_id` varchar(64) NOT NULL COMMENT '入库单ID',
|
||||
`plan_product_id` varchar(64) NOT NULL COMMENT '计划产品行ID',
|
||||
`product_id` varchar(64) DEFAULT NULL COMMENT 'CRM产品ID',
|
||||
`product_name` varchar(200) DEFAULT NULL COMMENT '产品名称快照',
|
||||
`specification` varchar(200) DEFAULT NULL COMMENT '规格快照',
|
||||
`unit` varchar(32) DEFAULT NULL COMMENT '单位',
|
||||
`plan_qty` decimal(15,3) DEFAULT NULL COMMENT '计划数量',
|
||||
`actual_qty` decimal(15,3) DEFAULT NULL COMMENT '实际数量',
|
||||
`qty_source` varchar(20) DEFAULT 'plan' COMMENT 'plan/dibang/manual',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pl_inbound_item_order` (`order_id`),
|
||||
KEY `idx_pl_inbound_item_plan_prod` (`plan_product_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输计划-入库单明细';
|
||||
|
||||
-- ── 2. 出库主从表(对称)──
|
||||
CREATE TABLE IF NOT EXISTS `pl_outbound_order` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`order_no` varchar(32) NOT NULL COMMENT '出库单号',
|
||||
`plan_id` varchar(64) NOT NULL COMMENT '运输计划ID',
|
||||
`transport_id` varchar(64) NOT NULL COMMENT '运输单ID',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'in_progress' COMMENT 'in_progress/completed/voided',
|
||||
`partner_company` varchar(200) DEFAULT NULL COMMENT '往来单位快照',
|
||||
`vehicle_no` varchar(32) DEFAULT NULL COMMENT '车牌快照',
|
||||
`plan_no` varchar(64) DEFAULT NULL COMMENT '计划号快照',
|
||||
`plan_date` date DEFAULT NULL COMMENT '计划日期快照',
|
||||
`site_access_token` varchar(64) NOT NULL COMMENT '现场免登录令牌',
|
||||
`site_feedback_remark` varchar(500) DEFAULT NULL COMMENT '现场备注',
|
||||
`site_feedback_images` text COMMENT '现场附件URLs(JSON数组)',
|
||||
`site_feedback_time` datetime DEFAULT NULL COMMENT '最近现场提交时间',
|
||||
`site_first_submitted_at` datetime DEFAULT NULL COMMENT '首次现场提交时间',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_pl_outbound_order_no` (`tenant_no`,`order_no`),
|
||||
UNIQUE KEY `uk_pl_outbound_site_token` (`site_access_token`),
|
||||
KEY `idx_pl_outbound_plan` (`tenant_no`,`plan_id`),
|
||||
KEY `idx_pl_outbound_transport` (`tenant_no`,`transport_id`),
|
||||
KEY `idx_pl_outbound_status` (`tenant_no`,`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输计划-出库单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pl_outbound_order_item` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`order_id` varchar(64) NOT NULL COMMENT '出库单ID',
|
||||
`plan_product_id` varchar(64) NOT NULL COMMENT '计划产品行ID',
|
||||
`product_id` varchar(64) DEFAULT NULL COMMENT 'CRM产品ID',
|
||||
`product_name` varchar(200) DEFAULT NULL COMMENT '产品名称快照',
|
||||
`specification` varchar(200) DEFAULT NULL COMMENT '规格快照',
|
||||
`unit` varchar(32) DEFAULT NULL COMMENT '单位',
|
||||
`plan_qty` decimal(15,3) DEFAULT NULL COMMENT '计划数量',
|
||||
`actual_qty` decimal(15,3) DEFAULT NULL COMMENT '实际数量',
|
||||
`qty_source` varchar(20) DEFAULT 'plan' COMMENT 'plan/dibang/manual',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pl_outbound_item_order` (`order_id`),
|
||||
KEY `idx_pl_outbound_item_plan_prod` (`plan_product_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输计划-出库单明细';
|
||||
|
||||
-- ── 3. 菜单:库存管理分组 + 入库单/出库单 ──
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES
|
||||
('PL_GROUP_STOCK', '库存管理', 'PL_MENU_ROOT', 'M', 'stock-module', NULL, NULL, 1, '1', '1', 'shanxia2.0', 2, '入库/出库单', 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_INBOUND', '入库单', 'PL_GROUP_STOCK', 'C', 'inbound', 'pl/inbound/index', 'pl:inbound:list', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_OUTBOUND', '出库单', 'PL_GROUP_STOCK', 'C', 'outbound', 'pl/outbound/index', 'pl:outbound:list', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_INBOUND_Q', '查询', 'PL_INBOUND', 'F', '', '', 'pl:inbound:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_INBOUND_E', '编辑', 'PL_INBOUND', 'F', '', '', 'pl:inbound:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_OUTBOUND_Q', '查询', 'PL_OUTBOUND', 'F', '', '', 'pl:outbound:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0),
|
||||
('PL_OUTBOUND_E', '编辑', 'PL_OUTBOUND', 'F', '', '', 'pl:outbound:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
menu_name = VALUES(menu_name),
|
||||
parent_id = VALUES(parent_id),
|
||||
path = VALUES(path),
|
||||
component = VALUES(component),
|
||||
perms = VALUES(perms),
|
||||
order_num = VALUES(order_num),
|
||||
update_time = NOW();
|
||||
|
||||
-- 购买证分组排序后移,给库存管理让出 order_num=2
|
||||
UPDATE sys_menu SET order_num = 3, update_time = NOW() WHERE id = 'PL_GROUP_CERT' AND del_flag = 0;
|
||||
UPDATE sys_menu SET order_num = 4, update_time = NOW() WHERE id = 'PL_GROUP_FREIGHT' AND del_flag = 0;
|
||||
UPDATE sys_menu SET order_num = 5, update_time = NOW() WHERE id = 'PL_GROUP_VEHICLE' AND del_flag = 0;
|
||||
UPDATE sys_menu SET order_num = 6, update_time = NOW() WHERE id = 'PL_GROUP_DOC' AND del_flag = 0;
|
||||
UPDATE sys_menu SET order_num = 7, update_time = NOW() WHERE id = 'PL_GROUP_BASE' AND del_flag = 0;
|
||||
|
||||
-- 拥有运输计划权限的角色,补全库存管理菜单
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT DISTINCT
|
||||
CONCAT('PLSTM_', SUBSTRING(MD5(CONCAT(rm.role_id, '-', m.id)), 1, 24)),
|
||||
rm.role_id,
|
||||
m.id,
|
||||
COALESCE(rm.tenant_no, 'shanxia2.0'),
|
||||
0,
|
||||
'pl stock menu auto grant',
|
||||
'system',
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role_menu rm
|
||||
CROSS JOIN (
|
||||
SELECT 'PL_GROUP_STOCK' AS id
|
||||
UNION ALL SELECT 'PL_INBOUND'
|
||||
UNION ALL SELECT 'PL_OUTBOUND'
|
||||
UNION ALL SELECT 'PL_INBOUND_Q'
|
||||
UNION ALL SELECT 'PL_INBOUND_E'
|
||||
UNION ALL SELECT 'PL_OUTBOUND_Q'
|
||||
UNION ALL SELECT 'PL_OUTBOUND_E'
|
||||
) m
|
||||
WHERE rm.del_flag = 0
|
||||
AND rm.menu_id IN ('PL_PLAN', 'PL_PLAN_ARCHIVE', 'PL_MENU_ROOT', 'PL_GROUP_PLAN')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.role_id = rm.role_id AND x.menu_id = m.id AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,117 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 报销 · 发票夹额度:建表 + 菜单(报销分组下方)
|
||||
-- 可重复执行(CREATE IF NOT EXISTS;菜单 NOT EXISTS 防护)
|
||||
-- ============================================================
|
||||
|
||||
-- 1) bx_invoice 发票档案
|
||||
CREATE TABLE IF NOT EXISTS `bx_invoice` (
|
||||
`id` VARCHAR(64) NOT NULL COMMENT '主键UUID',
|
||||
`owner_user_id` VARCHAR(64) NOT NULL COMMENT '所属员工ID',
|
||||
`owner_user_name` VARCHAR(64) NOT NULL COMMENT '所属员工姓名',
|
||||
`amount` DECIMAL(14,2) NOT NULL COMMENT '入账金额',
|
||||
`upload_by` VARCHAR(64) DEFAULT NULL COMMENT '上传操作人ID',
|
||||
`upload_by_name` VARCHAR(64) DEFAULT NULL COMMENT '上传操作人姓名',
|
||||
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
|
||||
`order_num` INT DEFAULT NULL,
|
||||
`remark` VARCHAR(512) DEFAULT NULL,
|
||||
`create_by` VARCHAR(64) DEFAULT NULL,
|
||||
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
`update_by` VARCHAR(64) DEFAULT NULL,
|
||||
`update_time` DATETIME NOT NULL,
|
||||
`del_flag` SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_bx_invoice_owner` (`tenant_no`,`owner_user_id`,`del_flag`,`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票档案';
|
||||
|
||||
-- 2) bx_invoice_ledger 额度流水(权威)
|
||||
CREATE TABLE IF NOT EXISTS `bx_invoice_ledger` (
|
||||
`id` VARCHAR(64) NOT NULL,
|
||||
`owner_user_id` VARCHAR(64) NOT NULL,
|
||||
`entry_type` VARCHAR(32) NOT NULL COMMENT 'credit_upload|debit_delete|debit_reimburse|credit_release',
|
||||
`amount` DECIMAL(14,2) NOT NULL COMMENT '正数,方向由 entry_type 决定',
|
||||
`invoice_id` VARCHAR(64) DEFAULT NULL,
|
||||
`reimburse_id` VARCHAR(64) DEFAULT NULL,
|
||||
`ref_ledger_id` VARCHAR(64) DEFAULT NULL COMMENT '退回指向原占用流水',
|
||||
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
|
||||
`order_num` INT DEFAULT NULL,
|
||||
`remark` VARCHAR(512) DEFAULT NULL,
|
||||
`create_by` VARCHAR(64) DEFAULT NULL,
|
||||
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
`update_by` VARCHAR(64) DEFAULT NULL,
|
||||
`update_time` DATETIME DEFAULT NULL,
|
||||
`del_flag` SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_bx_ledger_owner` (`tenant_no`,`owner_user_id`,`del_flag`,`create_time`),
|
||||
KEY `idx_bx_ledger_reimburse` (`tenant_no`,`reimburse_id`,`entry_type`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票额度流水';
|
||||
|
||||
-- 3) bx_reimburse_invoice 报销关联发票(M:N,可选)
|
||||
CREATE TABLE IF NOT EXISTS `bx_reimburse_invoice` (
|
||||
`id` VARCHAR(64) NOT NULL,
|
||||
`reimburse_id` VARCHAR(64) NOT NULL,
|
||||
`invoice_id` VARCHAR(64) NOT NULL,
|
||||
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
|
||||
`order_num` INT DEFAULT NULL,
|
||||
`remark` VARCHAR(512) DEFAULT NULL,
|
||||
`create_by` VARCHAR(64) DEFAULT NULL,
|
||||
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
`update_by` VARCHAR(64) DEFAULT NULL,
|
||||
`update_time` DATETIME DEFAULT NULL,
|
||||
`del_flag` SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_bx_ri_reimburse` (`tenant_no`,`reimburse_id`,`del_flag`),
|
||||
KEY `idx_bx_ri_invoice` (`tenant_no`,`invoice_id`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报销关联发票';
|
||||
|
||||
-- 4) bx_invoice_wallet_summary 额度缓存
|
||||
CREATE TABLE IF NOT EXISTS `bx_invoice_wallet_summary` (
|
||||
`id` VARCHAR(64) NOT NULL,
|
||||
`owner_user_id` VARCHAR(64) NOT NULL,
|
||||
`owner_user_name` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`total_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
|
||||
`used_amount` DECIMAL(14,2) NOT NULL DEFAULT 0,
|
||||
`balance` DECIMAL(14,2) NOT NULL DEFAULT 0,
|
||||
`invoice_count` INT NOT NULL DEFAULT 0,
|
||||
`version` INT NOT NULL DEFAULT 0,
|
||||
`tenant_no` VARCHAR(64) NOT NULL DEFAULT 'shanxia2.0',
|
||||
`order_num` INT DEFAULT NULL,
|
||||
`remark` VARCHAR(512) DEFAULT NULL,
|
||||
`create_by` VARCHAR(64) DEFAULT NULL,
|
||||
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||
`create_time` DATETIME NOT NULL,
|
||||
`update_by` VARCHAR(64) DEFAULT NULL,
|
||||
`update_time` DATETIME NOT NULL,
|
||||
`del_flag` SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_bx_wallet_owner` (`tenant_no`,`owner_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发票夹额度缓存';
|
||||
|
||||
-- 5) 菜单:挂在「报销」分组 BX...00010 下,order_num 4/5
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
'BX00000000000000000000000000020', '发票夹管理', 'BX00000000000000000000000000010', 'C',
|
||||
'invoice-wallet-admin', '/bx/invoice-wallet/admin/index', 'bx:invoiceWallet:admin',
|
||||
1, '1', '1', 'shanxia2.0', 4, '管理员代传/删除发票;全员额度列表',
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'BX00000000000000000000000000020');
|
||||
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
'BX00000000000000000000000000021', '我的发票夹', 'BX00000000000000000000000000010', 'C',
|
||||
'invoice-wallet-mine', '/bx/invoice-wallet/mine/index', 'bx:invoiceWallet:mine',
|
||||
1, '1', '1', 'shanxia2.0', 5, '员工自助上传与查看本人发票夹',
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'BX00000000000000000000000000021');
|
||||
@@ -1,16 +0,0 @@
|
||||
-- 报销申请:改选申请人按钮权限
|
||||
-- 幂等:按 id 不存在时插入
|
||||
|
||||
INSERT INTO `sys_menu` (
|
||||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||||
)
|
||||
SELECT
|
||||
'BX10000000000000000000000000009', '改选申请人', 'BX00000000000000000000000000011', 'F',
|
||||
'', NULL, 'bx:reimburseApply:changeApplicant',
|
||||
1, '1', '1', 'shanxia2.0', 2, '报销申请新增/编辑时可改选申请人并代选其发票',
|
||||
NULL, '', NOW(), NULL, NOW(), 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `id` = 'BX10000000000000000000000000009'
|
||||
);
|
||||
@@ -1,65 +0,0 @@
|
||||
-- 基础信息库:全部车辆/驾押人员拆分为三方与山峡四菜单
|
||||
|
||||
-- 三方车辆(原全部车辆信息)
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '三方车辆', order_num = 2, remark = '承运商/三方车辆', update_time = NOW()
|
||||
WHERE id = 'PL_VEHICLE' AND del_flag = 0;
|
||||
|
||||
-- 三方驾押人员(新建)
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) SELECT
|
||||
'PL_PERSONNEL_CARRIER', '三方驾押人员', 'PL_GROUP_BASE', 'C',
|
||||
'personnel-carrier', 'pl/personnel/index', 'pl:personnel-carrier:list',
|
||||
1, '1', '1', 'shanxia2.0', 3, '非山峡驾押人员',
|
||||
'system', '', NOW(), NULL, NOW(), 0
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu WHERE id = 'PL_PERSONNEL_CARRIER' AND del_flag = 0
|
||||
);
|
||||
|
||||
-- 山峡自有车辆
|
||||
UPDATE sys_menu
|
||||
SET order_num = 4, update_time = NOW()
|
||||
WHERE id = 'PL_SELF_VEHICLE' AND del_flag = 0;
|
||||
|
||||
-- 山峡驾押人员(原驾押人员信息)
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '山峡驾押人员', order_num = 5, update_time = NOW()
|
||||
WHERE id = 'PL_DRIVER' AND del_flag = 0;
|
||||
|
||||
-- 运输地址库顺延
|
||||
UPDATE sys_menu
|
||||
SET order_num = 6, update_time = NOW()
|
||||
WHERE id = 'PL_TRANSPORT_ADDRESS' AND del_flag = 0;
|
||||
|
||||
-- 拥有 PL_DRIVER 或 PL_VEHICLE 权限的角色,补全三方驾押人员菜单
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT DISTINCT
|
||||
CONCAT('PLPCM_', SUBSTRING(MD5(CONCAT(rm.role_id, '-PL_PERSONNEL_CARRIER')), 1, 24)),
|
||||
rm.role_id,
|
||||
'PL_PERSONNEL_CARRIER',
|
||||
COALESCE(rm.tenant_no, 'shanxia2.0'),
|
||||
0,
|
||||
'pl personnel carrier menu auto grant',
|
||||
'system',
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role_menu rm
|
||||
WHERE rm.del_flag = 0
|
||||
AND rm.menu_id IN ('PL_DRIVER', 'PL_VEHICLE')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_role_menu x
|
||||
WHERE x.role_id = rm.role_id
|
||||
AND x.menu_id = 'PL_PERSONNEL_CARRIER'
|
||||
AND x.del_flag = 0
|
||||
);
|
||||
@@ -1,276 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PR3 Task 2.4–2.7: API 层注入 user_info。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _inject_user_info_after_tenant(body: str) -> str:
|
||||
if "user_info = crm_user_info(_user)" in body:
|
||||
return body
|
||||
lines = body.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if "tenant_no = get_tenant_no(db, _user)" in line:
|
||||
indent = line[: len(line) - len(line.lstrip())]
|
||||
lines.insert(i + 1, f"{indent}user_info = crm_user_info(_user)")
|
||||
return "\n".join(lines)
|
||||
return body
|
||||
|
||||
|
||||
def patch_route_file(path: Path, route_prefixes: tuple[str, ...]):
|
||||
c = path.read_text()
|
||||
parts = re.split(r'(@router\.(?:post|get|put|delete)\("[^"]+"\))', c)
|
||||
if len(parts) < 2:
|
||||
# register_* pattern
|
||||
parts = re.split(r'( @router\.(?:post|get|put|delete)\("[^"]+"\))', c)
|
||||
out = [parts[0]]
|
||||
for i in range(1, len(parts), 2):
|
||||
dec = parts[i]
|
||||
body = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
m = re.search(r'@router\.(?:post|get|put|delete)\("([^"]+)"\)', dec)
|
||||
rp = m.group(1) if m else ""
|
||||
if any(rp.startswith(p) for p in route_prefixes):
|
||||
body = _inject_user_info_after_tenant(body)
|
||||
out.append(dec)
|
||||
out.append(body)
|
||||
c = "".join(out)
|
||||
path.write_text(c)
|
||||
print(f"injected user_info blocks in {path.name}")
|
||||
|
||||
|
||||
def patch_crm_py_calls():
|
||||
path = ROOT / "app/api/crm.py"
|
||||
c = path.read_text()
|
||||
|
||||
# imports
|
||||
if "ensure_crm_contact_row_in_scope" not in c:
|
||||
c = c.replace(
|
||||
" ensure_crm_owner_row_in_scope,\n)",
|
||||
" ensure_crm_contact_row_in_scope,\n ensure_crm_customer_linked_row_in_scope,\n ensure_crm_owner_row_in_scope,\n)",
|
||||
)
|
||||
|
||||
replacements = [
|
||||
# product categories
|
||||
(
|
||||
"rows = list_category_tree(\n db,\n tenant_no,\n category_name=category_name,\n status=status,\n )",
|
||||
"rows = list_category_tree(\n db,\n tenant_no,\n category_name=category_name,\n status=status,\n user_info=user_info,\n )",
|
||||
),
|
||||
(
|
||||
"obj = update_category(db, category_id, user_id=user_id, **data)",
|
||||
"obj = update_category(db, category_id, user_id=user_id, user_info=user_info, **data)",
|
||||
),
|
||||
("soft_delete_category(db, body.id)", "soft_delete_category(db, body.id, user_info=user_info)"),
|
||||
# products
|
||||
(
|
||||
"rows, total = list_products(\n db,\n tenant_no,\n page=body.page,\n page_size=body.pageSize,\n keyword=body.keyword,\n category_id=body.category_id,\n status=body.status,\n )",
|
||||
"rows, total = list_products(\n db,\n tenant_no,\n page=body.page,\n page_size=body.pageSize,\n keyword=body.keyword,\n category_id=body.category_id,\n status=body.status,\n user_info=user_info,\n )",
|
||||
),
|
||||
(
|
||||
"obj = get_product(db, body.id)\n if not obj:\n return error_response(404, \"产品不存在\")\n return success_response(_product_row_to_dict(obj))",
|
||||
"tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n obj = get_product(db, body.id)\n if not obj:\n return error_response(404, \"产品不存在\")\n ensure_crm_create_by_row_in_scope(db, obj, user_info, tenant_no, message=\"无权访问该产品\")\n return success_response(_product_row_to_dict(obj))",
|
||||
),
|
||||
(
|
||||
"obj = update_product(db, product_id, user_id=user_id, **data)",
|
||||
"tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n obj = update_product(db, product_id, user_id=user_id, user_info=user_info, **data)",
|
||||
),
|
||||
(
|
||||
"soft_delete_product(db, pid)",
|
||||
"soft_delete_product(db, pid, user_info=user_info)",
|
||||
),
|
||||
(
|
||||
"update_product(db, pid, user_id=user_id, status=body.status)",
|
||||
"update_product(db, pid, user_id=user_id, user_info=user_info, status=body.status)",
|
||||
),
|
||||
(
|
||||
"options = list_product_options(\n db,\n tenant_no,\n keyword=body.keyword,\n category_id=body.category_id,\n )",
|
||||
"options = list_product_options(\n db,\n tenant_no,\n keyword=body.keyword,\n category_id=body.category_id,\n user_info=user_info,\n )",
|
||||
),
|
||||
# contact
|
||||
(
|
||||
"rows, total = list_contacts(\n db, tenant_no, page=page, page_size=page_size,",
|
||||
"user_info = crm_user_info(_user)\n rows, total = list_contacts(\n db, tenant_no, page=page, page_size=page_size, user_info=user_info,",
|
||||
),
|
||||
(
|
||||
"row = get_contact(db, contact_id)\n if not row:\n return error_response(404, \"联系人不存在\")\n return success_response(contact_row_to_dict(row))",
|
||||
"tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n row = get_contact(db, contact_id)\n if not row:\n return error_response(404, \"联系人不存在\")\n ensure_crm_contact_row_in_scope(db, row, user_info, tenant_no, message=\"无权访问该联系人\")\n return success_response(contact_row_to_dict(row))",
|
||||
),
|
||||
(
|
||||
"rows = list_contacts_by_customer(db, tenant_no, customer_id)",
|
||||
"user_info = crm_user_info(_user)\n rows = list_contacts_by_customer(db, tenant_no, customer_id, user_info=user_info)",
|
||||
),
|
||||
(
|
||||
"obj = create_contact(\n db, tenant_no=tenant_no, user_id=user_id,",
|
||||
"user_info = crm_user_info(_user)\n obj = create_contact(\n db, tenant_no=tenant_no, user_id=user_id, user_info=user_info,",
|
||||
),
|
||||
(
|
||||
"obj = update_contact(db, contact_id, user_id=user_id, **data)",
|
||||
"tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n obj = update_contact(db, contact_id, user_id=user_id, tenant_no=tenant_no, user_info=user_info, **data)",
|
||||
),
|
||||
(
|
||||
"soft_delete_contact(db, cid)",
|
||||
"tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n user_id = _user.get(\"user_id\", \"\") or _user.get(\"id\", \"\")\n soft_delete_contact(db, cid, user_id=user_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
),
|
||||
(
|
||||
"obj = set_primary_contact(db, contact_id, user_id=user_id)",
|
||||
"tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n obj = set_primary_contact(db, contact_id, user_id=user_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
),
|
||||
]
|
||||
|
||||
for old, new in replacements:
|
||||
if old in c and new not in c:
|
||||
c = c.replace(old, new, 1)
|
||||
|
||||
# Fix contact delete indentation if broken
|
||||
c = c.replace(
|
||||
"for cid in body.ids:\n tenant_no = get_tenant_no(db, _user)",
|
||||
"tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n user_id = _user.get(\"user_id\", \"\") or _user.get(\"id\", \"\")\n for cid in body.ids:",
|
||||
)
|
||||
|
||||
# supplier / invoice - inject user_info in list handlers via route patch; add call params
|
||||
c = re.sub(
|
||||
r"list_supplier_info\(\s*\n\s*db, tenant_no,",
|
||||
"list_supplier_info(\n db, tenant_no, user_info=user_info,",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"list_all_supplier_info\(\s*\n\s*db, tenant_no,",
|
||||
"list_all_supplier_info(\n db, tenant_no, user_info=user_info,",
|
||||
c,
|
||||
)
|
||||
c = c.replace(
|
||||
"obj = update_supplier_info(\n db,\n supplier_id,\n tenant_no=tenant_no,\n user_id=user_id,",
|
||||
"obj = update_supplier_info(\n db,\n supplier_id,\n tenant_no=tenant_no,\n user_id=user_id,\n user_info=user_info,",
|
||||
)
|
||||
c = c.replace(
|
||||
"soft_delete_supplier_info(\n db,\n sid,\n tenant_no=tenant_no,\n user_id=user_id,",
|
||||
"soft_delete_supplier_info(\n db,\n sid,\n tenant_no=tenant_no,\n user_id=user_id,\n user_info=user_info,",
|
||||
)
|
||||
c = c.replace(
|
||||
"rows = list_invoice_info_by_customer(db, tenant_no, customer_id)",
|
||||
"rows = list_invoice_info_by_customer(db, tenant_no, customer_id, user_info=user_info)",
|
||||
)
|
||||
c = c.replace(
|
||||
"obj = create_invoice_info(\n db,\n tenant_no=tenant_no,\n user_id=user_id,",
|
||||
"obj = create_invoice_info(\n db,\n tenant_no=tenant_no,\n user_id=user_id,\n user_info=user_info,",
|
||||
)
|
||||
c = c.replace(
|
||||
"obj = update_invoice_info(\n db,\n invoice_id,\n tenant_no=tenant_no,\n user_id=user_id,",
|
||||
"obj = update_invoice_info(\n db,\n invoice_id,\n tenant_no=tenant_no,\n user_id=user_id,\n user_info=user_info,",
|
||||
)
|
||||
c = c.replace(
|
||||
"soft_delete_invoice_info(db, invoice_id, user_id=user_id)",
|
||||
"soft_delete_invoice_info(db, invoice_id, user_id=user_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
)
|
||||
c = c.replace(
|
||||
"obj = set_default_invoice_info(\n db,\n invoice_id,\n customer_id,\n tenant_no=tenant_no,\n user_id=user_id,",
|
||||
"obj = set_default_invoice_info(\n db,\n invoice_id,\n customer_id,\n tenant_no=tenant_no,\n user_id=user_id,\n user_info=user_info,",
|
||||
)
|
||||
|
||||
# product list/export need user_info at start of try
|
||||
c = c.replace(
|
||||
'"""分页查询产品列表"""\n try:\n tenant_no = get_tenant_no(db, _user)\n rows, total = list_products(',
|
||||
'"""分页查询产品列表"""\n try:\n tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n rows, total = list_products(',
|
||||
)
|
||||
c = c.replace(
|
||||
'"""获取产品分类树"""\n try:\n tenant_no = get_tenant_no(db, _user)',
|
||||
'"""获取产品分类树"""\n try:\n tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)',
|
||||
)
|
||||
c = c.replace(
|
||||
'"""删除产品(支持单条 body.id 和批量 body.ids)"""\n try:\n ids_to_delete',
|
||||
'"""删除产品(支持单条 body.id 和批量 body.ids)"""\n try:\n tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n ids_to_delete',
|
||||
)
|
||||
c = c.replace(
|
||||
'"""批量上架/下架"""\n try:\n user_id = _user.get("user_id", "") or _user.get("id", "")',
|
||||
'"""批量上架/下架"""\n try:\n tenant_no = get_tenant_no(db, _user)\n user_info = crm_user_info(_user)\n user_id = _user.get("user_id", "") or _user.get("id", "")',
|
||||
)
|
||||
|
||||
path.write_text(c)
|
||||
print("patched crm.py calls")
|
||||
|
||||
|
||||
def patch_quote_routes():
|
||||
path = ROOT / "app/api/crm_quote_routes.py"
|
||||
c = path.read_text()
|
||||
if "crm_user_info" not in c:
|
||||
c = c.replace(
|
||||
"from app.core.tenant_context import get_tenant_no\n",
|
||||
"from app.core.tenant_context import get_tenant_no\n"
|
||||
"from app.crud.crm.data_scope_helpers import crm_user_info\n",
|
||||
)
|
||||
|
||||
# inject user_info in each handler try block after tenant_no
|
||||
parts = re.split(r'( @router\.post\("[^"]+"\))', c)
|
||||
out = [parts[0]]
|
||||
for i in range(1, len(parts), 2):
|
||||
dec, body = parts[i], parts[i + 1] if i + 1 < len(parts) else ""
|
||||
body = _inject_user_info_after_tenant(body)
|
||||
out.extend([dec, body])
|
||||
c = "".join(out)
|
||||
|
||||
# pass user_info to crud calls
|
||||
c = re.sub(
|
||||
r"list_quotes\(\s*\n\s*db,\s*\n\s*tenant_no,",
|
||||
"list_quotes(\n db,\n tenant_no,\n user_info=user_info,",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"get_quote_detail\(\s*\n\s*db,",
|
||||
"get_quote_detail(\n db,",
|
||||
c,
|
||||
)
|
||||
c = c.replace(
|
||||
"tenant_no=tenant_no,\n )",
|
||||
"tenant_no=tenant_no,\n user_info=user_info,\n )",
|
||||
)
|
||||
# Too broad - fix manually with targeted replaces
|
||||
path.write_text(c)
|
||||
print("patched crm_quote_routes.py (partial)")
|
||||
|
||||
|
||||
def patch_contract_routes():
|
||||
path = ROOT / "app/api/crm_contract_routes.py"
|
||||
c = path.read_text()
|
||||
if "crm_user_info" not in c:
|
||||
c = c.replace(
|
||||
"from app.core.tenant_context import get_tenant_no\n",
|
||||
"from app.core.tenant_context import get_tenant_no\n"
|
||||
"from app.crud.crm.data_scope_helpers import crm_user_info\n",
|
||||
)
|
||||
parts = re.split(r'( @router\.(?:get|post|put|delete)\("[^"]+"\))', c)
|
||||
out = [parts[0]]
|
||||
for i in range(1, len(parts), 2):
|
||||
dec, body = parts[i], parts[i + 1] if i + 1 < len(parts) else ""
|
||||
body = _inject_user_info_after_tenant(body)
|
||||
out.extend([dec, body])
|
||||
c = "".join(out)
|
||||
c = re.sub(
|
||||
r"list_contracts\(\s*\n\s*db,\s*\n\s*tenant_no,",
|
||||
"list_contracts(\n db,\n tenant_no,\n user_info=user_info,",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"get_contract_detail\(\s*\n\s*db,",
|
||||
"get_contract_detail(\n db,",
|
||||
c,
|
||||
)
|
||||
path.write_text(c)
|
||||
print("patched crm_contract_routes.py (partial)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
patch_route_file(
|
||||
ROOT / "app/api/crm.py",
|
||||
(
|
||||
"/product-categories/",
|
||||
"/products/",
|
||||
"/contact/",
|
||||
"/supplierInfo/",
|
||||
"/invoice-info/",
|
||||
),
|
||||
)
|
||||
patch_crm_py_calls()
|
||||
patch_quote_routes()
|
||||
patch_contract_routes()
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""为 quote/contract 路由 CRUD 调用补全 user_info=user_info。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
QUOTE_FUNCS = (
|
||||
"get_quote_detail",
|
||||
"update_quote",
|
||||
"soft_delete_quotes",
|
||||
"submit_quote",
|
||||
"save_template",
|
||||
"get_template",
|
||||
"generate_pdf",
|
||||
"generate_share",
|
||||
"accept_quote",
|
||||
"reject_quote",
|
||||
"to_contract",
|
||||
"create_new_version",
|
||||
"query_versions",
|
||||
"withdraw_quote_approval",
|
||||
"invalid_quote",
|
||||
"statistics_overview",
|
||||
"statistics_status",
|
||||
"statistics_trend",
|
||||
"list_quote_approvals",
|
||||
"approve_quote",
|
||||
"withdraw_approval",
|
||||
"refresh_approver_names",
|
||||
"list_quote_products",
|
||||
"insert_quote_product",
|
||||
"update_quote_product",
|
||||
"soft_delete_quote_products",
|
||||
"list_share_logs",
|
||||
"share_log_statistics",
|
||||
)
|
||||
|
||||
CONTRACT_FUNCS = (
|
||||
"get_contract_detail",
|
||||
"get_contract",
|
||||
"update_contract",
|
||||
"soft_delete_contracts",
|
||||
"submit_contract_approval",
|
||||
"withdraw_contract_approval",
|
||||
"sign_contract",
|
||||
"start_execute",
|
||||
"finish_contract",
|
||||
"invalid_contract",
|
||||
"renew_contract",
|
||||
"batch_assign_owner",
|
||||
"batch_submit_contract_approval",
|
||||
"upload_attachments",
|
||||
"upload_attachments_with_category",
|
||||
"upload_file",
|
||||
"save_template",
|
||||
"get_template",
|
||||
"download_contract_pdf",
|
||||
"create_from_quote",
|
||||
"create_from_opportunity",
|
||||
"export_contracts",
|
||||
"query_expiring",
|
||||
"statistics_overview",
|
||||
"statistics_by_status",
|
||||
"statistics_by_type",
|
||||
"statistics_trend",
|
||||
"list_contract_approvals",
|
||||
"approve_contract_by_contract_id",
|
||||
"list_contract_changes",
|
||||
"get_contract_change_detail",
|
||||
"update_contract_change",
|
||||
"soft_delete_contract_changes",
|
||||
"list_contract_templates",
|
||||
"update_contract_template",
|
||||
"soft_delete_contract_templates",
|
||||
)
|
||||
|
||||
|
||||
def _add_user_info_kwarg(content: str, func_names: tuple[str, ...]) -> str:
|
||||
for fn in func_names:
|
||||
pattern = rf"({re.escape(fn)}\([^)]*?)(\n\s*\))"
|
||||
while True:
|
||||
m = re.search(pattern, content, re.DOTALL)
|
||||
if not m:
|
||||
break
|
||||
call = m.group(1)
|
||||
if "user_info=" in call:
|
||||
content = content[: m.start()] + call + m.group(2) + content[m.end() :]
|
||||
break
|
||||
new_call = call.rstrip() + ",\n user_info=user_info,"
|
||||
content = content[: m.start()] + new_call + m.group(2) + content[m.end() :]
|
||||
return content
|
||||
|
||||
|
||||
def patch_file(rel: str, funcs: tuple[str, ...]):
|
||||
path = ROOT / rel
|
||||
c = path.read_text()
|
||||
if "crm_user_info" not in c:
|
||||
c = c.replace(
|
||||
"from app.core.tenant_context import get_tenant_no\n",
|
||||
"from app.core.tenant_context import get_tenant_no\n"
|
||||
"from app.crud.crm.data_scope_helpers import crm_user_info\n",
|
||||
)
|
||||
c2 = _add_user_info_kwarg(c, funcs)
|
||||
path.write_text(c2)
|
||||
print(f"patched {rel}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
patch_file("app/api/crm_quote_routes.py", QUOTE_FUNCS)
|
||||
patch_file("app/api/crm_contract_routes.py", CONTRACT_FUNCS)
|
||||
sub = ROOT / "app/api/crm_contract_sub_routes.py"
|
||||
if sub.exists():
|
||||
patch_file("app/api/crm_contract_sub_routes.py", CONTRACT_FUNCS)
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PR3 Task 2.6–2.7: quote/contract CRUD 数据权限补丁。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def patch_quote_py():
|
||||
path = ROOT / "app/crud/crm/quote.py"
|
||||
c = path.read_text()
|
||||
if "apply_crm_owner_scope" in c:
|
||||
print("quote.py already patched")
|
||||
return
|
||||
|
||||
import_block = """from app.crud.crm.data_scope_helpers import apply_crm_owner_scope, ensure_crm_owner_row_in_scope
|
||||
|
||||
"""
|
||||
c = c.replace(
|
||||
"from app.services.crm_quote_pdf import generate_quote_pdf\n\n",
|
||||
"from app.services.crm_quote_pdf import generate_quote_pdf\n"
|
||||
+ import_block,
|
||||
)
|
||||
|
||||
helper = '''
|
||||
def _ensure_quote_in_scope(db: Session, row: CrmQuote, user_info, tenant_no: str) -> None:
|
||||
ensure_crm_owner_row_in_scope(db, row, user_info, tenant_no, message="无权访问该报价单")
|
||||
|
||||
|
||||
def _get_quote_in_scope(
|
||||
db: Session,
|
||||
quote_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_info=None,
|
||||
) -> CrmQuote:
|
||||
obj = get_quote(db, quote_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("报价单不存在")
|
||||
_ensure_quote_in_scope(db, obj, user_info, tenant_no)
|
||||
return obj
|
||||
|
||||
'''
|
||||
c = c.replace("_DATE_FMT = \"%Y-%m-%d\"\n\n\n", "_DATE_FMT = \"%Y-%m-%d\"\n" + helper + "\n")
|
||||
|
||||
# list_quotes
|
||||
c = c.replace(
|
||||
" current_user_id: str = None,\n) -> Tuple[List[Dict[str, Any]], int]:\n \"\"\"Java: queryPageList\"\"\"\n q = db.query(CrmQuote)\n q = _build_quote_filters(db, q, filters or {}, tenant_no=tenant_no)",
|
||||
" current_user_id: str = None,\n user_info=None,\n) -> Tuple[List[Dict[str, Any]], int]:\n \"\"\"Java: queryPageList\"\"\"\n q = db.query(CrmQuote)\n q = _build_quote_filters(db, q, filters or {}, tenant_no=tenant_no)\n if user_info:\n q = apply_crm_owner_scope(q, CrmQuote, user_info, db, tenant_no)",
|
||||
)
|
||||
|
||||
# get_quote_detail
|
||||
c = c.replace(
|
||||
"def get_quote_detail(\n db: Session,\n quote_id: str,\n *,\n tenant_no: str = None,\n) -> Dict[str, Any]:",
|
||||
"def get_quote_detail(\n db: Session,\n quote_id: str,\n *,\n tenant_no: str = None,\n user_info=None,\n) -> Dict[str, Any]:",
|
||||
)
|
||||
c = c.replace(
|
||||
' obj = get_quote(db, quote_id, tenant_no=tenant_no)\n if not obj:\n raise ValueError("报价单不存在")\n\n detail = _row_to_dict(obj)',
|
||||
' obj = get_quote(db, quote_id, tenant_no=tenant_no)\n if not obj:\n raise ValueError("报价单不存在")\n _ensure_quote_in_scope(db, obj, user_info, tenant_no or obj.tenant_no)\n\n detail = _row_to_dict(obj)',
|
||||
)
|
||||
|
||||
# Mutations: replace get_quote with _get_quote_in_scope where entity is used for mutation
|
||||
mutation_funcs = [
|
||||
"update_quote",
|
||||
"soft_delete_quotes",
|
||||
"submit_quote",
|
||||
"save_template",
|
||||
"get_template",
|
||||
"generate_pdf",
|
||||
"generate_share",
|
||||
"accept_quote",
|
||||
"reject_quote",
|
||||
"to_contract",
|
||||
"create_new_version",
|
||||
"query_versions",
|
||||
"withdraw_quote_approval",
|
||||
"invalid_quote",
|
||||
]
|
||||
for fn in mutation_funcs:
|
||||
# add user_info to signature before closing paren of def
|
||||
pat = rf"(def {fn}\([^)]*)(\n\) ->)"
|
||||
if re.search(pat, c, re.DOTALL):
|
||||
block = re.search(rf"def {fn}\((.*?)\n\) ->", c, re.DOTALL)
|
||||
if block and "user_info" not in block.group(1):
|
||||
c = re.sub(
|
||||
rf"(def {fn}\(.*?)((?:,\n)?\n\) ->)",
|
||||
r"\1,\n user_info=None,\2",
|
||||
c,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Replace entity = get_quote patterns in mutation bodies
|
||||
c = re.sub(
|
||||
r"(\n old = )get_quote\(db, quote_id, tenant_no=tenant_no\)",
|
||||
r"\1_get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"(\n entity = )get_quote\(db, qid, tenant_no=tenant_no\)",
|
||||
r"\1_get_quote_in_scope(db, qid, tenant_no=tenant_no, user_info=user_info)",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"(\n entity = )get_quote\(db, quote_id, tenant_no=tenant_no\)",
|
||||
r"\1_get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
c,
|
||||
)
|
||||
|
||||
# statistics
|
||||
for fn in ("statistics_overview", "statistics_status", "statistics_trend"):
|
||||
pat = rf"def {fn}\("
|
||||
if f"def {fn}(" in c and f"def {fn}(" in c:
|
||||
block = re.search(rf"def {fn}\((.*?)\n\) ->", c, re.DOTALL)
|
||||
if block and "user_info" not in block.group(1):
|
||||
c = re.sub(
|
||||
rf"(def {fn}\(.*?)((?:,\n)?\n\) ->)",
|
||||
r"\1,\n user_info=None,\2",
|
||||
c,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Add scope to statistics queries - find q = db.query(CrmQuote) in statistics funcs
|
||||
c = c.replace(
|
||||
' q = db.query(CrmQuote).filter(\n CrmQuote.tenant_no == tenant_no,\n CrmQuote.del_flag == 0,\n )\n if quote_type:',
|
||||
' q = db.query(CrmQuote).filter(\n CrmQuote.tenant_no == tenant_no,\n CrmQuote.del_flag == 0,\n )\n if user_info:\n q = apply_crm_owner_scope(q, CrmQuote, user_info, db, tenant_no)\n if quote_type:',
|
||||
)
|
||||
|
||||
path.write_text(c)
|
||||
print("patched quote.py")
|
||||
|
||||
|
||||
def patch_contract_py():
|
||||
path = ROOT / "app/crud/crm/contract.py"
|
||||
c = path.read_text()
|
||||
if "apply_crm_owner_scope" in c:
|
||||
print("contract.py already patched")
|
||||
return
|
||||
|
||||
# find first import from app.
|
||||
c = c.replace(
|
||||
"from app.core.utils import generate_uuid\n",
|
||||
"from app.core.utils import generate_uuid\n"
|
||||
"from app.crud.crm.data_scope_helpers import apply_crm_owner_scope, ensure_crm_owner_row_in_scope\n",
|
||||
)
|
||||
|
||||
helper = '''
|
||||
def _ensure_contract_in_scope(db: Session, row: CrmContract, user_info, tenant_no: str) -> None:
|
||||
ensure_crm_owner_row_in_scope(db, row, user_info, tenant_no, message="无权访问该合同")
|
||||
|
||||
|
||||
def _get_contract_row_in_scope(
|
||||
db: Session,
|
||||
contract_id: str,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_info=None,
|
||||
) -> CrmContract:
|
||||
row = get_contract_row(db, contract_id, tenant_no=tenant_no)
|
||||
if not row:
|
||||
raise ValueError("合同不存在")
|
||||
_ensure_contract_in_scope(db, row, user_info, tenant_no)
|
||||
return row
|
||||
|
||||
'''
|
||||
# Insert after CrmContract import line area - find _FMT
|
||||
c = c.replace("_FMT = \"%Y-%m-%d %H:%M:%S\"\n", "_FMT = \"%Y-%m-%d %H:%M:%S\"\n" + helper + "\n")
|
||||
|
||||
c = c.replace(
|
||||
" current_user_id: str = None,\n) -> Tuple[List[Dict[str, Any]], int]:\n \"\"\"Java: queryPageList\"\"\"\n q = db.query(CrmContract)\n q = _build_contract_filters(db, q, filters or {}, tenant_no=tenant_no)",
|
||||
" current_user_id: str = None,\n user_info=None,\n) -> Tuple[List[Dict[str, Any]], int]:\n \"\"\"Java: queryPageList\"\"\"\n q = db.query(CrmContract)\n q = _build_contract_filters(db, q, filters or {}, tenant_no=tenant_no)\n if user_info:\n q = apply_crm_owner_scope(q, CrmContract, user_info, db, tenant_no)",
|
||||
)
|
||||
|
||||
c = c.replace(
|
||||
"def get_contract(\n db: Session,\n contract_id: str,\n *,\n tenant_no: str,\n current_user_id: str = None,\n) -> Optional[Dict[str, Any]]:",
|
||||
"def get_contract(\n db: Session,\n contract_id: str,\n *,\n tenant_no: str,\n current_user_id: str = None,\n user_info=None,\n) -> Optional[Dict[str, Any]]:",
|
||||
)
|
||||
c = c.replace(
|
||||
" if not row:\n return None\n\n detail = _row_to_dict(row)",
|
||||
" if not row:\n return None\n _ensure_contract_in_scope(db, row, user_info, tenant_no)\n\n detail = _row_to_dict(row)",
|
||||
)
|
||||
|
||||
c = c.replace(
|
||||
"def get_contract_detail(\n db: Session,\n contract_id: str,\n *,\n tenant_no: str,\n current_user_id: str = None,\n) -> Dict[str, Any]:",
|
||||
"def get_contract_detail(\n db: Session,\n contract_id: str,\n *,\n tenant_no: str,\n current_user_id: str = None,\n user_info=None,\n) -> Dict[str, Any]:",
|
||||
)
|
||||
c = c.replace(
|
||||
" detail = get_contract(\n db,\n contract_id,\n tenant_no=tenant_no,\n current_user_id=current_user_id,\n )",
|
||||
" detail = get_contract(\n db,\n contract_id,\n tenant_no=tenant_no,\n current_user_id=current_user_id,\n user_info=user_info,\n )",
|
||||
)
|
||||
|
||||
mutation_funcs = [
|
||||
"update_contract",
|
||||
"soft_delete_contracts",
|
||||
"submit_contract_approval",
|
||||
"withdraw_contract_approval",
|
||||
"approve_contract_by_contract_id",
|
||||
"sign_contract",
|
||||
"start_execute",
|
||||
"finish_contract",
|
||||
"invalid_contract",
|
||||
"renew_contract",
|
||||
"batch_assign_owner",
|
||||
"batch_submit_contract_approval",
|
||||
"upload_attachments",
|
||||
"upload_attachments_with_category",
|
||||
"upload_file",
|
||||
"save_template",
|
||||
"get_template",
|
||||
"download_contract_pdf",
|
||||
"create_from_quote",
|
||||
"create_from_opportunity",
|
||||
"export_contracts",
|
||||
"query_expiring",
|
||||
"statistics_overview",
|
||||
"statistics_by_status",
|
||||
"statistics_by_type",
|
||||
"statistics_trend",
|
||||
]
|
||||
for fn in mutation_funcs:
|
||||
block = re.search(rf"def {fn}\((.*?)\n\) ->", c, re.DOTALL)
|
||||
if block and "user_info" not in block.group(1):
|
||||
c = re.sub(
|
||||
rf"(def {fn}\(.*?)((?:,\n)?\n\) ->)",
|
||||
r"\1,\n user_info=None,\2",
|
||||
c,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Replace get_contract_row with scoped version in mutations
|
||||
c = re.sub(
|
||||
r"(\n row = )get_contract_row\(db, contract_id, tenant_no=tenant_no\)",
|
||||
r"\1_get_contract_row_in_scope(db, contract_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"(\n entity = )get_contract_row\(db, cid, tenant_no=tenant_no\)",
|
||||
r"\1_get_contract_row_in_scope(db, cid, tenant_no=tenant_no, user_info=user_info)",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"(\n entity = )get_contract_row\(db, contract_id, tenant_no=tenant_no\)",
|
||||
r"\1_get_contract_row_in_scope(db, contract_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
c,
|
||||
)
|
||||
c = re.sub(
|
||||
r"(\n old = )get_contract_row\(db, contract_id, tenant_no=tenant_no\)",
|
||||
r"\1_get_contract_row_in_scope(db, contract_id, tenant_no=tenant_no, user_info=user_info)",
|
||||
c,
|
||||
)
|
||||
|
||||
path.write_text(c)
|
||||
print("patched contract.py")
|
||||
|
||||
|
||||
def patch_quote_product_py():
|
||||
path = ROOT / "app/crud/crm/quote_product.py"
|
||||
c = path.read_text()
|
||||
if "_ensure_quote_product_scope" in c:
|
||||
print("quote_product.py already patched")
|
||||
return
|
||||
c = c.replace(
|
||||
"from app.core.utils import generate_uuid\n",
|
||||
"from app.core.utils import generate_uuid\n"
|
||||
"from app.crud.crm.quote import _get_quote_in_scope\n",
|
||||
)
|
||||
|
||||
helper = '''
|
||||
def _ensure_quote_parent_scope(db: Session, quote_id: str, tenant_no: str, user_info) -> None:
|
||||
if user_info:
|
||||
_get_quote_in_scope(db, quote_id, tenant_no=tenant_no, user_info=user_info)
|
||||
|
||||
'''
|
||||
c = c.replace("_FMT = \"%Y-%m-%d %H:%M:%S\"\n\n", "_FMT = \"%Y-%m-%d %H:%M:%S\"\n" + helper + "\n")
|
||||
|
||||
for fn in ("list_quote_products", "insert_quote_product", "update_quote_product", "soft_delete_quote_products"):
|
||||
block = re.search(rf"def {fn}\((.*?)\n\) ->", c, re.DOTALL)
|
||||
if block and "user_info" not in block.group(1):
|
||||
c = re.sub(
|
||||
rf"(def {fn}\(.*?)((?:,\n)?\n\) ->)",
|
||||
r"\1,\n user_info=None,\2",
|
||||
c,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
c = c.replace(
|
||||
' """Java: queryListByQuoteId"""\n q = db.query(CrmQuoteProduct)',
|
||||
' """Java: queryListByQuoteId"""\n if tenant_no and user_info:\n _ensure_quote_parent_scope(db, quote_id, tenant_no, user_info)\n q = db.query(CrmQuoteProduct)',
|
||||
)
|
||||
|
||||
path.write_text(c)
|
||||
print("patched quote_product.py (partial)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
patch_quote_py()
|
||||
patch_contract_py()
|
||||
patch_quote_product_py()
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""仅刷新 5 个业务角色的菜单授权(不删角色、不动用户绑定)。
|
||||
|
||||
用于修复「角色只勾 F 按钮、缺少 C 页面 :list」导致前端侧栏为空的问题。
|
||||
|
||||
用法:
|
||||
cd backend && ./venv/bin/python scripts/refresh_business_role_menus.py --env-file .env.production
|
||||
./venv/bin/python scripts/refresh_business_role_menus.py --dry-run --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from scripts.setup_business_roles import ( # noqa: E402
|
||||
MODULE_MENU_ROOTS,
|
||||
NEW_ROLES,
|
||||
TENANT_NO,
|
||||
collect_subtree_menu_ids,
|
||||
grant_module_menus,
|
||||
perm_menu_ids,
|
||||
)
|
||||
|
||||
|
||||
def load_settings_env(env_file: str) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
path = Path(env_file) if env_file else BACKEND_ROOT / ".env.production"
|
||||
if not path.is_absolute():
|
||||
path = BACKEND_ROOT / path
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def verify_user_menu_codes(engine, user_no: str) -> None:
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.web_auth_roles import iter_user_menu_perm_codes
|
||||
|
||||
with Session(engine) as db:
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM sys_user
|
||||
WHERE tenant_no = :tenant AND user_no = :user_no AND del_flag = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant": TENANT_NO, "user_no": user_no},
|
||||
).first()
|
||||
if not row:
|
||||
print(f" [verify] 用户不存在: {user_no}")
|
||||
return
|
||||
uid = str(row[0])
|
||||
codes = sorted(iter_user_menu_perm_codes(db, uid, TENANT_NO))
|
||||
list_codes = [c for c in codes if c.endswith(":list")]
|
||||
print(f" [verify] {user_no}: 共 {len(codes)} 个 perms,其中 :list {len(list_codes)} 个")
|
||||
if list_codes:
|
||||
print(f" 示例: {', '.join(list_codes[:5])}...")
|
||||
else:
|
||||
print(" 警告: 无任何 :list 权限,前端侧栏仍会为空")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="刷新业务角色菜单授权")
|
||||
parser.add_argument("--env-file", default=".env.production")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--verify-user", default="方金强", help="刷新后校验该用户的 menuCodes")
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file)
|
||||
settings = load_runtime_settings()
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
print("=== refresh_business_role_menus ===")
|
||||
print(f"模式: {'DRY-RUN' if args.dry_run else 'EXECUTE'}")
|
||||
print(f"租户: {TENANT_NO}")
|
||||
print()
|
||||
|
||||
with engine.begin() as conn:
|
||||
subtree = collect_subtree_menu_ids(conn, MODULE_MENU_ROOTS, TENANT_NO)
|
||||
grant_ids = perm_menu_ids(conn, subtree, TENANT_NO)
|
||||
print(f"模块子树 {len(subtree)} 项,带 perms 菜单 {len(grant_ids)} 项")
|
||||
for role_name, role_id in NEW_ROLES.items():
|
||||
cnt = grant_module_menus(conn, role_id, grant_ids, TENANT_NO, args.dry_run)
|
||||
print(f" {role_name}: {cnt} 项")
|
||||
|
||||
if not args.dry_run and args.verify_user:
|
||||
print()
|
||||
print("校验 menuCodes:")
|
||||
verify_user_menu_codes(engine, args.verify_user)
|
||||
|
||||
print("完成.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""将门户 crm_product.product_no 统一为 SXWL + 6 位流水,并写出编号映射 CSV。
|
||||
|
||||
设计:docs/superpowers/specs/2026-07-12-crm-product-no-renumber-design.md
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
venv/bin/python scripts/renumber_crm_product_no.py --dry-run --env-file .env.production
|
||||
|
||||
venv/bin/python scripts/renumber_crm_product_no.py --apply --env-file .env.production \\
|
||||
--mapping-out ../docs/data-migration/mappings/crm_product_no_mapping.csv
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
DEFAULT_TENANT_NO = "shanxia2.0"
|
||||
PRODUCT_NO_PREFIX = "SXWL"
|
||||
PRODUCT_NO_SEQ_LEN = 6
|
||||
NEW_NO_PATTERN = re.compile(rf"^{PRODUCT_NO_PREFIX}[0-9]{{{PRODUCT_NO_SEQ_LEN}}}$")
|
||||
TEMP_PREFIX = "__REN_"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenumberStats:
|
||||
total: int = 0
|
||||
to_update: int = 0
|
||||
skipped: int = 0
|
||||
updated: int = 0
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_settings_env(env_file: Optional[str]) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
candidates: List[Path] = []
|
||||
if env_file:
|
||||
p = Path(env_file)
|
||||
candidates.append(p if p.is_absolute() else BACKEND_ROOT / p)
|
||||
candidates.extend([BACKEND_ROOT / ".env.production", BACKEND_ROOT / ".env"])
|
||||
for path in candidates:
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
break
|
||||
|
||||
|
||||
def product_id_sort_key(product_id: str) -> int:
|
||||
try:
|
||||
return int(product_id)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def format_new_product_no(seq: int) -> str:
|
||||
if seq < 1 or seq > 10**PRODUCT_NO_SEQ_LEN - 1:
|
||||
raise ValueError(f"序号超出范围: {seq}")
|
||||
return f"{PRODUCT_NO_PREFIX}{seq:0{PRODUCT_NO_SEQ_LEN}d}"
|
||||
|
||||
|
||||
def fetch_products(engine: Engine, tenant_no: str) -> List[dict]:
|
||||
sql = text(
|
||||
"""
|
||||
SELECT id, product_no, product_name, category_id
|
||||
FROM crm_product
|
||||
WHERE tenant_no = :tenant_no AND del_flag = 0
|
||||
"""
|
||||
)
|
||||
with engine.connect() as conn:
|
||||
rows = [dict(r._mapping) for r in conn.execute(sql, {"tenant_no": tenant_no})]
|
||||
rows.sort(key=lambda r: product_id_sort_key(str(r["id"])))
|
||||
return rows
|
||||
|
||||
|
||||
def build_mapping(rows: List[dict], renumbered_at: Optional[str] = None) -> List[dict]:
|
||||
mapping: List[dict] = []
|
||||
for seq, row in enumerate(rows, start=1):
|
||||
pid = str(row["id"])
|
||||
old_no = (row.get("product_no") or "").strip()
|
||||
new_no = format_new_product_no(seq)
|
||||
mapping.append(
|
||||
{
|
||||
"product_id": pid,
|
||||
"old_product_no": old_no,
|
||||
"new_product_no": new_no,
|
||||
"product_name": (row.get("product_name") or "")[:200],
|
||||
"category_id": str(row.get("category_id") or ""),
|
||||
"seq": seq,
|
||||
"renumbered_at": renumbered_at or "",
|
||||
}
|
||||
)
|
||||
return mapping
|
||||
|
||||
|
||||
def validate_mapping(mapping: List[dict], stats: RenumberStats) -> None:
|
||||
new_nos = [m["new_product_no"] for m in mapping]
|
||||
if len(new_nos) != len(set(new_nos)):
|
||||
stats.warnings.append("新编号存在重复")
|
||||
old_nos = [m["old_product_no"] for m in mapping if m["old_product_no"]]
|
||||
if len(old_nos) != len(set(old_nos)):
|
||||
stats.warnings.append("旧编号存在重复(仍将按 product_id 映射)")
|
||||
for m in mapping:
|
||||
if not NEW_NO_PATTERN.match(m["new_product_no"]):
|
||||
stats.warnings.append(f"非法新编号: {m['new_product_no']} (id={m['product_id']})")
|
||||
|
||||
|
||||
def write_mapping_csv(path: Path, mapping: List[dict], renumbered_at: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for row in mapping:
|
||||
if not row.get("renumbered_at"):
|
||||
row["renumbered_at"] = renumbered_at
|
||||
fieldnames = [
|
||||
"product_id",
|
||||
"old_product_no",
|
||||
"new_product_no",
|
||||
"product_name",
|
||||
"category_id",
|
||||
"seq",
|
||||
"renumbered_at",
|
||||
]
|
||||
with path.open("w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(mapping)
|
||||
|
||||
|
||||
def apply_renumber(engine: Engine, mapping: List[dict], tenant_no: str, stats: RenumberStats) -> None:
|
||||
"""两阶段 UPDATE,避免 uk_product_no 中间态冲突。"""
|
||||
phase1_sql = text(
|
||||
"""
|
||||
UPDATE crm_product
|
||||
SET product_no = :temp_no, update_time = NOW()
|
||||
WHERE id = :id AND tenant_no = :tenant_no AND del_flag = 0
|
||||
"""
|
||||
)
|
||||
phase2_sql = text(
|
||||
"""
|
||||
UPDATE crm_product
|
||||
SET product_no = :new_no, update_time = NOW()
|
||||
WHERE id = :id AND tenant_no = :tenant_no AND del_flag = 0
|
||||
"""
|
||||
)
|
||||
|
||||
with engine.begin() as conn:
|
||||
for m in mapping:
|
||||
old_no = m["old_product_no"]
|
||||
new_no = m["new_product_no"]
|
||||
if old_no == new_no:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
stats.to_update += 1
|
||||
temp_no = f"{TEMP_PREFIX}{m['product_id']}"[:50]
|
||||
conn.execute(
|
||||
phase1_sql,
|
||||
{"temp_no": temp_no, "id": m["product_id"], "tenant_no": tenant_no},
|
||||
)
|
||||
for m in mapping:
|
||||
old_no = m["old_product_no"]
|
||||
new_no = m["new_product_no"]
|
||||
if old_no == new_no:
|
||||
continue
|
||||
result = conn.execute(
|
||||
phase2_sql,
|
||||
{"new_no": new_no, "id": m["product_id"], "tenant_no": tenant_no},
|
||||
)
|
||||
stats.updated += int(result.rowcount or 0)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="CRM 产品编号统一为 SXWL + 6 位流水")
|
||||
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写库")
|
||||
parser.add_argument("--apply", action="store_true", help="执行 UPDATE 并写映射")
|
||||
parser.add_argument("--env-file", default=None, help="如 .env.production")
|
||||
parser.add_argument(
|
||||
"--mapping-out",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/crm_product_no_mapping.csv"),
|
||||
help="编号映射 CSV 输出路径",
|
||||
)
|
||||
parser.add_argument("--tenant-no", default=DEFAULT_TENANT_NO)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.apply and args.dry_run:
|
||||
print("请只指定 --dry-run 或 --apply 之一")
|
||||
return 1
|
||||
if not args.apply and not args.dry_run:
|
||||
args.dry_run = True
|
||||
|
||||
load_settings_env(args.env_file)
|
||||
from app.core.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
stats = RenumberStats()
|
||||
rows = fetch_products(engine, args.tenant_no)
|
||||
stats.total = len(rows)
|
||||
if stats.total == 0:
|
||||
print("目标库无有效产品,退出")
|
||||
return 1
|
||||
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
mapping = build_mapping(rows, renumbered_at=now_str if args.apply else "")
|
||||
validate_mapping(mapping, stats)
|
||||
|
||||
for m in mapping:
|
||||
if m["old_product_no"] != m["new_product_no"]:
|
||||
stats.to_update += 1
|
||||
else:
|
||||
stats.skipped += 1
|
||||
|
||||
mode = "DRY-RUN" if args.dry_run else "APPLY"
|
||||
print(f"租户: {args.tenant_no}")
|
||||
print(f"有效产品: {stats.total}")
|
||||
print(f"需更新编号: {stats.to_update} 已符合跳过: {stats.skipped}")
|
||||
print("\n抽样映射(前 5 / 后 3):")
|
||||
for m in mapping[:5]:
|
||||
print(f" id={m['product_id']} {m['old_product_no']!r} -> {m['new_product_no']}")
|
||||
if len(mapping) > 8:
|
||||
print(" ...")
|
||||
for m in mapping[-3:]:
|
||||
print(f" id={m['product_id']} {m['old_product_no']!r} -> {m['new_product_no']}")
|
||||
next_seq = stats.total + 1
|
||||
print(f"\n下一自动编号应为: {format_new_product_no(next_seq)}")
|
||||
|
||||
if stats.warnings:
|
||||
print("\n警告:")
|
||||
for w in stats.warnings:
|
||||
print(f" - {w}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n=== 产品编号统一 [DRY-RUN] ===")
|
||||
print("未写库。确认后加 --apply --mapping-out ...")
|
||||
return 0
|
||||
|
||||
mapping_path = Path(args.mapping_out)
|
||||
if not mapping_path.is_absolute():
|
||||
mapping_path = BACKEND_ROOT / mapping_path
|
||||
|
||||
apply_stats = RenumberStats(total=stats.total)
|
||||
apply_renumber(engine, mapping, args.tenant_no, apply_stats)
|
||||
|
||||
write_mapping_csv(mapping_path, mapping, now_str)
|
||||
|
||||
print(f"\n映射已写入: {mapping_path}")
|
||||
print("\n=== 产品编号统一 [COMMITTED] ===")
|
||||
print(f"更新行数: {apply_stats.updated}")
|
||||
print(f"跳过(已符合): {apply_stats.skipped}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""手动触发地磅计划按月自动生成(与 APScheduler 任务同逻辑)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.db.auto_generate_plans import run_auto_generate_monthly_plans
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run db monthly plan auto-generation once")
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Skip first-day-of-month guard (for local verification)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = run_auto_generate_monthly_plans(db, force=args.force)
|
||||
print(result)
|
||||
return 0
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,176 +0,0 @@
|
||||
-- 山峡2.0 sys_menu 全量菜单种子数据(Web端 + 移动端)
|
||||
-- 与 web-admin 路由 sqxp.ts / pl.ts 对齐
|
||||
-- 执行前请确认已清空 sys_menu(及按需重置 sys_role_menu)
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('2', 'Web端', '0', 'M', '/web', NULL, NULL, 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-05-27 16:26:29', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('1', '移动端', '0', 'M', '/mini', NULL, NULL, 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-05-27 16:26:29', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM00000000000000000000000000001', 'CRM管理', '2', 'M', '/crm', NULL, NULL, 1, '1', '1', 'shanxia2.0', 1, 'CRM客户关系管理模块', NULL, '', '2026-05-30 16:53:06', NULL, '2026-05-30 17:29:15', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000001', '报销管理', '2', 'M', '/bx', NULL, NULL, 1, '1', '1', 'shanxia2.0', 2, 'bx 报销业务模块', NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB00000000000000000000000000001', '地磅管理', '2', 'M', '/db', NULL, NULL, 1, '1', '1', 'shanxia2.0', 3, '地磅业务模块(计划/出入库/地磅单/车牌)', NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_MENU_ROOT', '运输管理', '2', 'M', '/pl', NULL, '', 1, '1', '1', 'shanxia2.0', 4, '运输管理父菜单', 'system', '', '2026-06-11 05:17:12', NULL, '2026-06-21 09:34:26', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('WKF00000000000000000000000000001', '工作流管理', '2', 'M', '/workflow', '/index/index', NULL, 1, '1', '1', 'shanxia2.0', 5, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('SYSM000000000000000000000000000001', '系统管理', '2', 'M', '/system', '/index/index', NULL, 1, '1', '1', 'shanxia2.0', 6, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM00000000000000000000000000002', '产品分类', 'CRM00000000000000000000000000001', 'C', 'product-category', '/crm/product-category/index', 'crm:productCategory:list', 1, '1', '1', 'shanxia2.0', 1, '产品分类树形管理', NULL, '', '2026-05-30 16:53:06', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM00000000000000000000000000003', '产品管理', 'CRM00000000000000000000000000001', 'C', 'product', '/crm/product/index', 'crm:product:list', 1, '1', '1', 'shanxia2.0', 2, '产品列表管理,含Excel导入导出', NULL, '', '2026-05-30 16:53:06', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000010', '线索管理', 'CRM00000000000000000000000000001', 'C', 'clue', '/crm/clue/index', 'crm:clue:list', 1, '1', '1', 'shanxia2.0', 3, 'CRM线索列表与跟进', NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000000010', '商机管理', 'CRM00000000000000000000000000001', 'C', 'opportunity', '/crm/opportunity/index', 'crm:opportunity:list', 1, '1', '1', 'shanxia2.0', 4, 'CRM商机列表与跟进', NULL, '', '2026-05-31 16:16:28', NULL, '2026-05-31 16:16:28', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000010', '报价单管理', 'CRM00000000000000000000000000001', 'C', 'quote', '/crm/quote/index', 'crm:quote:list', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000020', '合同管理', 'CRM00000000000000000000000000001', 'C', 'contract', '/crm/contract/index', 'crm:contract:list', 1, '1', '1', 'shanxia2.0', 6, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000030', '调价函管理', 'CRM00000000000000000000000000001', 'C', 'price-letter', '/crm/price-letter/index', 'crm:priceLetter:list', 1, '1', '1', 'shanxia2.0', 7, '独立调价函档案:接受(附件)/发出(富文本+可选附件)', 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000002', '客户管理', 'CRM00000000000000000000000000001', 'C', 'customer', '/crm/customer/index', 'crm:customer:list', 1, '1', '1', 'shanxia2.0', 8, '客户列表管理', NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000003', '公海池', 'CRM00000000000000000000000000001', 'C', 'customer-pool', '/crm/customer/pool/index', 'crm:customerPool:list', 1, '1', '1', 'shanxia2.0', 9, '公海池客户管理', NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000000011', '线索详情', 'CRM00000000000000000000000000001', 'C', 'clue/detail/:id', '/crm/clue/detail', 'crm:clue:query', 1, '0', '1', 'shanxia2.0', 10, '线索详情页(侧栏不展示)', NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000000011', '商机详情', 'CRM00000000000000000000000000001', 'C', 'opportunity/detail/:id', '/crm/opportunity/detail', 'crm:opportunity:query', 1, '0', '1', 'shanxia2.0', 11, '商机详情页(侧栏不展示)', NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000031', '调价函新增', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:add', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000032', '调价函编辑', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000034', '调价函查看', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:query', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000035', '调价函打印', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:print', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM3000000000000000000000000036', '调价函作废', 'CRM3000000000000000000000000030', 'F', '', NULL, 'crm:priceLetter:void', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-07-15 20:00:00', NULL, '2026-07-15 20:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000002', '基础配置', 'BX00000000000000000000000000001', 'M', 'config', NULL, NULL, 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000006', '备用金', 'BX00000000000000000000000000001', 'M', 'advance', NULL, NULL, 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000010', '报销', 'BX00000000000000000000000000001', 'M', 'reimburse', NULL, NULL, 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000014', '视图中心', 'BX00000000000000000000000000001', 'M', 'view', NULL, NULL, 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB00000000000000000000000000002', '仪表盘', 'DB00000000000000000000000000001', 'C', 'dashboard', '/db/dashboard/index', 'db:dashboard:list', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB00000000000000000000000000003', '地磅计划', 'DB00000000000000000000000000001', 'C', 'plans', '/db/plans/index', 'db:plan:list', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB00000000000000000000000000004', '地磅出入库', 'DB00000000000000000000000000001', 'C', 'inventory', '/db/inventory/index', 'db:inventory:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB00000000000000000000000000005', '地磅单管理', 'DB00000000000000000000000000001', 'C', 'weighing', '/db/weighing/index', 'db:weighing:list', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_GROUP_PLAN', '运输计划', 'PL_MENU_ROOT', 'M', 'plan-module', NULL, NULL, 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_GROUP_CERT', '购买证上传', 'PL_MENU_ROOT', 'M', 'cert-upload', NULL, NULL, 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_GROUP_FREIGHT', '运输费用', 'PL_MENU_ROOT', 'M', 'freight-fee', NULL, NULL, 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_GROUP_VEHICLE', '车辆管理', 'PL_MENU_ROOT', 'M', 'vehicle-mgmt', NULL, NULL, 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_GROUP_DOC', '证件提醒', 'PL_MENU_ROOT', 'M', 'document-reminder-module', NULL, NULL, 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_GROUP_BASE', '基础信息库', 'PL_MENU_ROOT', 'M', 'base-info', NULL, NULL, 1, '1', '1', 'shanxia2.0', 6, NULL, 'system', '', '2026-07-05 21:00:00', NULL, '2026-07-05 21:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('10000000000000000000000000000wf01', '流程配置', 'WKF00000000000000000000000000001', 'C', 'process', '/system/workflow/process/index', 'workflow:process', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('10000000000000000000000000000wf02', '待办中心', 'WKF00000000000000000000000000001', 'C', 'pending', '/system/workflow/task-pending/index', 'workflow:pending', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('10000000000000000000000000000wf03', '已办中心', 'WKF00000000000000000000000000001', 'C', 'completed', '/system/workflow/task-completed/index', 'workflow:completed', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('10000000000000000000000000000wf05', '我的申请', 'WKF00000000000000000000000000001', 'C', 'my-applications', '/system/workflow/my-applications/index', 'workflow:myApplications', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('10000000000000000000000000000wf04', '请假申请', 'WKF00000000000000000000000000001', 'C', 'leave', '/system/leave/index', 'workflow:leave', 1, '1', '1', 'shanxia2.0', 5, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('101', '租户管理', 'SYSM000000000000000000000000000001', 'C', 'tenant', '/system/tenant/index', 'system:tenant:list', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('102', '字典管理', 'SYSM000000000000000000000000000001', 'C', 'dict', '/system/dict/index', 'system:dict:list', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('106', '菜单管理', 'SYSM000000000000000000000000000001', 'C', 'menu', '/system/menu/index', 'system:menu:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('103', '组织管理', 'SYSM000000000000000000000000000001', 'C', 'dept', '/system/dept/index', 'system:dept:list', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('105', '角色管理', 'SYSM000000000000000000000000000001', 'C', 'role', '/system/role/index', 'system:role:list', 1, '1', '1', 'shanxia2.0', 5, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('104', '用户管理', 'SYSM000000000000000000000000000001', 'C', 'user', '/system/user/index', 'system:user:list', 1, '1', '1', 'shanxia2.0', 6, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('108', '职位管理', 'SYSM000000000000000000000000000001', 'C', 'post', '/system/post/index', 'system:post:list', 1, '1', '1', 'shanxia2.0', 7, NULL, NULL, '', '2026-05-27 16:26:30', NULL, '2026-05-30 17:06:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('SYS_OPERLOG', '操作日志', 'SYSM000000000000000000000000000001', 'C', 'operlog', '/system/operlog/index', 'system:operlog:list', 1, '1', '1', 'shanxia2.0', 8, NULL, 'system', '', '2026-06-29 10:23:01', NULL, '2026-06-29 10:23:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001001', '客户新增', 'CRM1000000000000000000000000002', 'F', '', NULL, 'crm:customer:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001008', '公海领取', 'CRM1000000000000000000000000003', 'F', '', NULL, 'crm:customerPool:claim', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002001', '线索新增', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000003001', '商机新增', 'CRM2000000000000000000000000010', 'F', '', NULL, 'crm:opportunity:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001002', '客户编辑', 'CRM1000000000000000000000000002', 'F', '', NULL, 'crm:customer:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001009', '公海分配', 'CRM1000000000000000000000000003', 'F', '', NULL, 'crm:customerPool:assign', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002002', '线索编辑', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000003002', '商机编辑', 'CRM2000000000000000000000000010', 'F', '', NULL, 'crm:opportunity:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001003', '客户删除', 'CRM1000000000000000000000000002', 'F', '', NULL, 'crm:customer:remove', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002003', '线索删除', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:remove', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000003003', '商机删除', 'CRM2000000000000000000000000010', 'F', '', NULL, 'crm:opportunity:remove', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001004', '客户查看', 'CRM1000000000000000000000000002', 'F', '', NULL, 'crm:customer:query', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002004', '线索查看', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:query', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000003004', '商机查看', 'CRM2000000000000000000000000010', 'F', '', NULL, 'crm:opportunity:query', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001005', '客户转移', 'CRM1000000000000000000000000002', 'F', '', NULL, 'crm:customer:transfer', 1, '1', '1', 'shanxia2.0', 5, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002005', '线索导出', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:export', 1, '1', '1', 'shanxia2.0', 5, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000003005', '商机导出', 'CRM2000000000000000000000000010', 'F', '', NULL, 'crm:opportunity:export', 1, '1', '1', 'shanxia2.0', 5, NULL, NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001006', '客户成交', 'CRM1000000000000000000000000002', 'F', '', NULL, 'crm:customer:deal', 1, '1', '1', 'shanxia2.0', 6, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002006', '线索导入', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:import', 1, '1', '1', 'shanxia2.0', 6, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000003006', '商机分配', 'CRM2000000000000000000000000010', 'F', '', NULL, 'crm:opportunity:assign', 1, '1', '1', 'shanxia2.0', 6, NULL, NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000001007', '放入公海', 'CRM1000000000000000000000000002', 'F', '', NULL, 'crm:customer:toPool', 1, '1', '1', 'shanxia2.0', 7, NULL, NULL, '', '2026-05-30 22:08:47', NULL, '2026-05-30 22:08:47', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002007', '线索分配', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:assign', 1, '1', '1', 'shanxia2.0', 7, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM2000000000000000000000003007', '商机跟进', 'CRM2000000000000000000000000010', 'F', '', NULL, 'crm:opportunity:follow', 1, '1', '1', 'shanxia2.0', 7, NULL, NULL, '', '2026-05-31 16:17:46', NULL, '2026-05-31 16:17:46', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002008', '线索跟进', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:follow', 1, '1', '1', 'shanxia2.0', 8, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002009', '线索转化', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:convert', 1, '1', '1', 'shanxia2.0', 9, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('CRM1000000000000000000000002010', '标为无效', 'CRM1000000000000000000000000010', 'F', '', NULL, 'crm:clue:invalid', 1, '1', '1', 'shanxia2.0', 10, NULL, NULL, '', '2026-05-31 15:01:19', NULL, '2026-05-31 15:01:19', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000003', '费用项目管理', 'BX00000000000000000000000000002', 'C', 'expense-item', '/bx/expense-item/index', 'bx:expenseItem:list', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000007', '备用金申请', 'BX00000000000000000000000000006', 'C', 'apply', '/bx/advance/apply/index', 'bx:advanceApply:list', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000011', '报销申请', 'BX00000000000000000000000000010', 'C', 'apply', '/bx/reimburse/apply/index', 'bx:reimburseApply:list', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000015', '我的申请单', 'BX00000000000000000000000000014', 'C', 'mine', '/bx/view/mine/index', 'bx:viewMine:list', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000004', '工作流配置(备用金)', 'BX00000000000000000000000000002', 'C', 'workflow-advance', '/bx/workflow/advance/index', 'bx:workflowAdvance:list', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000008', '备用金审批', 'BX00000000000000000000000000006', 'C', 'approve', '/bx/advance/approve/index', 'bx:advanceApprove:list', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000012', '报销审批', 'BX00000000000000000000000000010', 'C', 'approve', '/bx/reimburse/approve/index', 'bx:reimburseApprove:list', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000016', '待我审批', 'BX00000000000000000000000000014', 'C', 'todo', '/bx/view/todo/index', 'bx:viewTodo:list', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000005', '工作流配置(报销单)', 'BX00000000000000000000000000002', 'C', 'workflow-reimburse', '/bx/workflow/reimburse/index', 'bx:workflowReimburse:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000009', '备用金发放', 'BX00000000000000000000000000006', 'C', 'issue', '/bx/advance/issue/index', 'bx:advanceIssue:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000013', '支付发放冲抵', 'BX00000000000000000000000000010', 'C', 'payment', '/bx/payment/index', 'bx:payment:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000020', '发票夹管理', 'BX00000000000000000000000000010', 'C', 'invoice-wallet-admin', '/bx/invoice-wallet/admin/index', 'bx:invoiceWallet:admin', 1, '1', '1', 'shanxia2.0', 4, '管理员代传/删除发票;全员额度列表', NULL, '', '2026-07-25 18:00:00', NULL, '2026-07-25 18:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000021', '我的发票夹', 'BX00000000000000000000000000010', 'C', 'invoice-wallet-mine', '/bx/invoice-wallet/mine/index', 'bx:invoiceWallet:mine', 1, '1', '1', 'shanxia2.0', 5, '员工自助上传与查看本人发票夹', NULL, '', '2026-07-25 18:00:00', NULL, '2026-07-25 18:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX00000000000000000000000000017', '全部申请单(总经理)', 'BX00000000000000000000000000014', 'C', 'all', '/bx/view/all/index', 'bx:viewAll:list', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000001', '计划新增', 'DB00000000000000000000000000003', 'F', '', NULL, 'db:plan:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000005', '出入库新增', 'DB00000000000000000000000000004', 'F', '', NULL, 'db:inventory:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000009', '地磅单新增', 'DB00000000000000000000000000005', 'F', '', NULL, 'db:weighing:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000002', '计划编辑', 'DB00000000000000000000000000003', 'F', '', NULL, 'db:plan:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000006', '出入库编辑', 'DB00000000000000000000000000004', 'F', '', NULL, 'db:inventory:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000010', '地磅单编辑', 'DB00000000000000000000000000005', 'F', '', NULL, 'db:weighing:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000003', '计划删除', 'DB00000000000000000000000000003', 'F', '', NULL, 'db:plan:remove', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000007', '出入库删除', 'DB00000000000000000000000000004', 'F', '', NULL, 'db:inventory:remove', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000011', '地磅单删除', 'DB00000000000000000000000000005', 'F', '', NULL, 'db:weighing:remove', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000004', '计划查看', 'DB00000000000000000000000000003', 'F', '', NULL, 'db:plan:query', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000008', '出入库查看', 'DB00000000000000000000000000004', 'F', '', NULL, 'db:inventory:query', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000012', '地磅单查看', 'DB00000000000000000000000000005', 'F', '', NULL, 'db:weighing:query', 1, '1', '1', 'shanxia2.0', 4, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('DB10000000000000000000000000013', '地磅单作废', 'DB00000000000000000000000000005', 'F', '', NULL, 'db:weighing:void', 1, '1', '1', 'shanxia2.0', 5, NULL, NULL, '', '2026-06-07 10:00:00', NULL, '2026-06-07 10:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_CARRIER', '承运商信息', 'PL_GROUP_BASE', 'C', 'carrier', 'pl/carrier/index', 'pl:carrier:list', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-11 05:17:12', NULL, '2026-06-11 05:17:12', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_DOC_REMINDER', '证件提醒', 'PL_GROUP_DOC', 'C', 'document-reminder', 'pl/inventory/document-reminder', 'pl:document:list', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-11 05:17:13', NULL, '2026-06-11 05:17:13', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT', '运费结算', 'PL_GROUP_FREIGHT', 'C', 'freight', 'pl/freight/index', 'pl:freight:list', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-11 05:17:13', NULL, '2026-06-28 16:41:25', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_PLAN', '运输计划', 'PL_GROUP_PLAN', 'C', 'plan', 'pl/plan/index', 'pl:plan:list', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-11 05:17:12', NULL, '2026-06-11 05:17:12', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_PLAN_CERT', '购买证上传', 'PL_GROUP_CERT', 'C', 'plan-cert', 'pl/plan-cert/index', 'pl:plan-cert:list', 1, '1', '1', 'shanxia2.0', 1, '危化品购买证/合法使用证明上传', 'system', '', '2026-06-20 18:18:10', NULL, '2026-06-25 06:14:17', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VEH_RECHARGE_APPLY', 'ETC加油申请', 'PL_GROUP_VEHICLE', 'C', 'vehicle-recharge-apply', 'pl/vehicle-recharge-apply/index', 'pl:vehicle-recharge-apply:list', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:37:03', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_APPROVAL', '运费结算审批', 'PL_GROUP_FREIGHT', 'C', 'freight-approval', 'pl/freight-approval/index', 'pl:freight-approval:list', 1, '1', '1', 'shanxia2.0', 2, '待审批与已审批结算单', 'system', '', '2026-06-28 16:41:25', NULL, '2026-06-28 16:41:25', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_PLAN_ARCHIVE', '运输计划归档', 'PL_GROUP_PLAN', 'C', 'plan-archive', 'pl/plan-archive/index', 'pl:plan-archive:list', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-25 06:14:17', NULL, '2026-06-25 06:14:17', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VEHICLE', '三方车辆', 'PL_GROUP_BASE', 'C', 'vehicle', 'pl/vehicle/index', 'pl:vehicle:list', 1, '1', '1', 'shanxia2.0', 2, '承运商/三方车辆', 'system', '', '2026-06-11 05:17:12', NULL, '2026-06-28 16:01:18', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_PERSONNEL_CARRIER', '三方驾押人员', 'PL_GROUP_BASE', 'C', 'personnel-carrier', 'pl/personnel/index', 'pl:personnel-carrier:list', 1, '1', '1', 'shanxia2.0', 3, '非山峡驾押人员', 'system', '', '2026-07-25 00:00:00', NULL, '2026-07-25 00:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VEH_RECHARGE_RECORD', 'ETC加油发放登记', 'PL_GROUP_VEHICLE', 'C', 'vehicle-recharge-record', 'pl/vehicle-recharge-record/index', 'pl:vehicle-recharge-record:list', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:37:03', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_DISBURSE_MENU', '运费财务发放', 'PL_GROUP_FREIGHT', 'C', 'freight-disburse', 'pl/freight-disburse/index', 'pl:freight-disburse:list', 1, '1', '1', 'shanxia2.0', 3, '待发放与已发放结算单', 'system', '', '2026-06-28 16:41:25', NULL, '2026-06-28 16:41:25', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_SELF_VEHICLE', '山峡自有车辆', 'PL_GROUP_BASE', 'C', 'self-vehicle', 'pl/self-vehicle/index', 'pl:self-vehicle:list', 1, '1', '1', 'shanxia2.0', 4, '展示全部自有类型车辆', 'system', '', '2026-06-20 08:07:00', NULL, '2026-06-28 16:01:18', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VEH_MAINT_APPLY', '车辆维保申请', 'PL_GROUP_VEHICLE', 'C', 'vehicle-maintenance-apply', 'pl/vehicle-maintenance-apply/index', 'pl:vehicle-maintenance-apply:list', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_DRIVER', '山峡驾押人员', 'PL_GROUP_BASE', 'C', 'personnel', 'pl/personnel/index', 'pl:driver:list', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-06-11 05:17:12', NULL, '2026-06-19 22:55:10', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VEH_MAINT_PROCESS', '车辆维保处理登记', 'PL_GROUP_VEHICLE', 'C', 'vehicle-maintenance-process', 'pl/vehicle-maintenance-process/index', 'pl:vehicle-maintenance-process:list', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_TRANSPORT_ADDRESS', '运输地址库', 'PL_GROUP_BASE', 'C', 'transport-address', 'pl/transport-address/index', 'pl:transport-address:list', 1, '1', '1', 'shanxia2.0', 6, '运输通用地址库', 'system', '', '2026-07-01 19:54:09', NULL, '2026-07-01 19:54:09', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VEH_UREA_APPLY', '车辆尿素申请', 'PL_GROUP_VEHICLE', 'C', 'vehicle-urea-apply', 'pl/vehicle-urea-apply/index', 'pl:vehicle-urea-apply:list', 1, '0', '1', 'shanxia2.0', 5, '侧栏不展示', 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VEH_UREA_DISBURSE', '车辆尿素发放登记', 'PL_GROUP_VEHICLE', 'C', 'vehicle-urea-disburse', 'pl/vehicle-urea-disburse/index', 'pl:vehicle-urea-disburse:list', 1, '0', '1', 'shanxia2.0', 6, '侧栏不展示', 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('SYS_OPERLOG_Q', '查询', 'SYS_OPERLOG', 'F', '', '', 'system:operlog:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 10:23:01', NULL, '2026-06-29 10:23:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000001', '费用项目新增', 'BX00000000000000000000000000003', 'F', '', NULL, 'bx:expenseItem:add', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000004', '备用金提交', 'BX00000000000000000000000000007', 'F', '', NULL, 'bx:advanceApply:submit', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000005', '备用金审批通过', 'BX00000000000000000000000000008', 'F', '', NULL, 'bx:advanceApprove:approve', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000006', '备用金发放', 'BX00000000000000000000000000009', 'F', '', NULL, 'bx:advanceIssue:issue', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000007', '报销提交', 'BX00000000000000000000000000011', 'F', '', NULL, 'bx:reimburseApply:submit', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000009', '改选申请人', 'BX00000000000000000000000000011', 'F', '', NULL, 'bx:reimburseApply:changeApplicant', 1, '1', '1', 'shanxia2.0', 2, '报销申请新增/编辑时可改选申请人并代选其发票', NULL, '', '2026-07-25 22:00:00', NULL, '2026-07-25 22:00:00', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000008', '报销支付', 'BX00000000000000000000000000013', 'F', '', NULL, 'bx:payment:pay', 1, '1', '1', 'shanxia2.0', 1, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_APPROVAL_Q', '查询', 'PL_FREIGHT_APPROVAL', 'F', '', '', 'pl:freight-approval:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-28 16:41:25', NULL, '2026-06-28 16:41:25', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_DISBURSE_Q', '查询', 'PL_FREIGHT_DISBURSE_MENU', 'F', '', '', 'pl:freight-disburse:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-28 16:41:25', NULL, '2026-06-28 16:41:25', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_Q', '查询', 'PL_FREIGHT', 'F', '', '', 'pl:freight:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-25 06:29:42', NULL, '2026-06-25 06:29:42', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_PLAN_ARCHIVE_Q', '查询', 'PL_PLAN_ARCHIVE', 'F', '', '', 'pl:plan-archive:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-25 06:14:17', NULL, '2026-06-25 06:14:17', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMA_Q', '查询', 'PL_VEH_MAINT_APPLY', 'F', '', '', 'pl:vehicle-maintenance-apply:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMP_Q', '查询', 'PL_VEH_MAINT_PROCESS', 'F', '', '', 'pl:vehicle-maintenance-process:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRA_Q', '查询', 'PL_VEH_RECHARGE_APPLY', 'F', '', '', 'pl:vehicle-recharge-apply:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRR_Q', '查询', 'PL_VEH_RECHARGE_RECORD', 'F', '', '', 'pl:vehicle-recharge-record:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUA_Q', '查询', 'PL_VEH_UREA_APPLY', 'F', '', '', 'pl:vehicle-urea-apply:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUD_Q', '查询', 'PL_VEH_UREA_DISBURSE', 'F', '', '', 'pl:vehicle-urea-disburse:query', 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000002', '费用项目编辑', 'BX00000000000000000000000000003', 'F', '', NULL, 'bx:expenseItem:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_C', '组单', 'PL_FREIGHT', 'F', '', '', 'pl:freight:create', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-25 06:29:42', NULL, '2026-06-25 06:29:42', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_PLAN_ARCHIVE_E', '编辑', 'PL_PLAN_ARCHIVE', 'F', '', '', 'pl:plan-archive:edit', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-25 06:14:17', NULL, '2026-06-25 06:14:17', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMA_A', '新增', 'PL_VEH_MAINT_APPLY', 'F', '', '', 'pl:vehicle-maintenance-apply:add', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMP_A', '新增', 'PL_VEH_MAINT_PROCESS', 'F', '', '', 'pl:vehicle-maintenance-process:add', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRA_A', '新增', 'PL_VEH_RECHARGE_APPLY', 'F', '', '', 'pl:vehicle-recharge-apply:add', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRR_A', '新增', 'PL_VEH_RECHARGE_RECORD', 'F', '', '', 'pl:vehicle-recharge-record:add', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUA_A', '新增', 'PL_VEH_UREA_APPLY', 'F', '', '', 'pl:vehicle-urea-apply:add', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUD_A', '新增', 'PL_VEH_UREA_DISBURSE', 'F', '', '', 'pl:vehicle-urea-disburse:add', 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('BX10000000000000000000000000003', '费用项目删除', 'BX00000000000000000000000000003', 'F', '', NULL, 'bx:expenseItem:remove', 1, '1', '1', 'shanxia2.0', 3, NULL, NULL, '', '2026-06-06 21:22:32', NULL, '2026-06-06 21:22:32', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_E', '编辑', 'PL_FREIGHT', 'F', '', '', 'pl:freight:edit', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-25 06:29:42', NULL, '2026-06-25 06:29:42', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_PLAN_ARCHIVE_A', '归档', 'PL_PLAN_ARCHIVE', 'F', '', '', 'pl:plan-archive:archive', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-25 06:14:17', NULL, '2026-06-25 06:14:17', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMA_E', '编辑', 'PL_VEH_MAINT_APPLY', 'F', '', '', 'pl:vehicle-maintenance-apply:edit', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMP_E', '编辑', 'PL_VEH_MAINT_PROCESS', 'F', '', '', 'pl:vehicle-maintenance-process:edit', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRA_E', '编辑', 'PL_VEH_RECHARGE_APPLY', 'F', '', '', 'pl:vehicle-recharge-apply:edit', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRR_E', '编辑', 'PL_VEH_RECHARGE_RECORD', 'F', '', '', 'pl:vehicle-recharge-record:edit', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUA_E', '编辑', 'PL_VEH_UREA_APPLY', 'F', '', '', 'pl:vehicle-urea-apply:edit', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUD_E', '编辑', 'PL_VEH_UREA_DISBURSE', 'F', '', '', 'pl:vehicle-urea-disburse:edit', 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_S', '提交审批', 'PL_FREIGHT', 'F', '', '', 'pl:freight:submit', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-25 06:29:42', NULL, '2026-06-25 06:29:42', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMA_R', '删除', 'PL_VEH_MAINT_APPLY', 'F', '', '', 'pl:vehicle-maintenance-apply:remove', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMP_R', '删除', 'PL_VEH_MAINT_PROCESS', 'F', '', '', 'pl:vehicle-maintenance-process:remove', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRA_R', '删除', 'PL_VEH_RECHARGE_APPLY', 'F', '', '', 'pl:vehicle-recharge-apply:remove', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRR_R', '删除', 'PL_VEH_RECHARGE_RECORD', 'F', '', '', 'pl:vehicle-recharge-record:remove', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUA_R', '删除', 'PL_VEH_UREA_APPLY', 'F', '', '', 'pl:vehicle-urea-apply:remove', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUD_R', '删除', 'PL_VEH_UREA_DISBURSE', 'F', '', '', 'pl:vehicle-urea-disburse:remove', 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_A', '审批', 'PL_FREIGHT', 'F', '', '', 'pl:freight:approve', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-06-25 06:29:42', NULL, '2026-06-25 06:29:42', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VMA_C', '作废', 'PL_VEH_MAINT_APPLY', 'F', '', '', 'pl:vehicle-maintenance-apply:cancel', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-06-29 09:02:16', NULL, '2026-06-29 09:13:01', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VRA_C', '作废', 'PL_VEH_RECHARGE_APPLY', 'F', '', '', 'pl:vehicle-recharge-apply:cancel', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-06-29 08:32:30', NULL, '2026-06-29 08:32:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_VUA_C', '作废', 'PL_VEH_UREA_APPLY', 'F', '', '', 'pl:vehicle-urea-apply:cancel', 1, '1', '1', 'shanxia2.0', 5, NULL, 'system', '', '2026-06-29 09:26:30', NULL, '2026-06-29 09:26:30', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_W', '撤回', 'PL_FREIGHT', 'F', '', '', 'pl:freight:withdraw', 1, '1', '1', 'shanxia2.0', 6, NULL, 'system', '', '2026-06-25 06:29:42', NULL, '2026-06-25 06:29:42', 0);
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('PL_FREIGHT_D', '发放登记', 'PL_FREIGHT', 'F', '', '', 'pl:freight:disburse', 1, '1', '1', 'shanxia2.0', 7, NULL, 'system', '', '2026-06-25 06:29:42', NULL, '2026-06-25 06:29:42', 0);
|
||||
@@ -1,412 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重置业务角色:保留超级管理员,创建 5 个销售体系角色并授权菜单、绑定用户。
|
||||
|
||||
用法:
|
||||
cd backend && ./venv/bin/python scripts/setup_business_roles.py --env-file .env.production
|
||||
./venv/bin/python scripts/setup_business_roles.py --dry-run --env-file .env.production
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
|
||||
TENANT_NO = "shanxia2.0"
|
||||
ADMIN_USER_ID = "d3000000000000000000000000000001"
|
||||
SUPER_ADMIN_ROLE_ID = "097bd2c6-4e72-11f1-9f47-00163e3bdd96"
|
||||
|
||||
# 四个一级业务模块根菜单
|
||||
MODULE_MENU_ROOTS = [
|
||||
"CRM00000000000000000000000000001", # CRM管理
|
||||
"BX00000000000000000000000000001", # 报销管理
|
||||
"DB00000000000000000000000000001", # 地磅管理
|
||||
"PL_MENU_ROOT", # 运输管理
|
||||
]
|
||||
|
||||
NEW_ROLES: Dict[str, str] = {
|
||||
"管理员": "R65CAAE057914CCCAE2A81BE627A5001",
|
||||
"销售主管": "R65CAAE057914CCCAE2A81BE627A5002",
|
||||
"销售计划员": "R65CAAE057914CCCAE2A81BE627A5003",
|
||||
"销售实录员": "R65CAAE057914CCCAE2A81BE627A5004",
|
||||
"销售办证员": "R65CAAE057914CCCAE2A81BE627A5005",
|
||||
}
|
||||
|
||||
USER_ROLE_ASSIGNMENTS: Dict[str, List[str]] = {
|
||||
"管理员": ["吴良萍", "吴九华", "汪璇"],
|
||||
"销售主管": ["方金强", "吴九华"],
|
||||
"销售计划员": ["程欢欢"],
|
||||
"销售实录员": ["郑丽娜"],
|
||||
"销售办证员": ["刘丹"],
|
||||
}
|
||||
|
||||
|
||||
def load_runtime_settings():
|
||||
from app.core.config import Settings
|
||||
|
||||
return Settings()
|
||||
|
||||
|
||||
def load_settings_env(env_file: str) -> None:
|
||||
if env_file:
|
||||
os.environ["ENV_FILE"] = env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
path = Path(env_file) if env_file else BACKEND_ROOT / ".env.production"
|
||||
if not path.is_absolute():
|
||||
path = BACKEND_ROOT / path
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
|
||||
|
||||
def collect_subtree_menu_ids(conn, root_ids: List[str], tenant_no: str) -> Set[str]:
|
||||
"""BFS 收集模块根下全部菜单(含中间目录)。"""
|
||||
all_ids: Set[str] = set(root_ids)
|
||||
frontier = list(root_ids)
|
||||
while frontier:
|
||||
placeholders = ", ".join(f":p{i}" for i in range(len(frontier)))
|
||||
params = {f"p{i}": frontier[i] for i in range(len(frontier))}
|
||||
params["tenant"] = tenant_no
|
||||
rows = conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id FROM sys_menu
|
||||
WHERE del_flag = 0 AND tenant_no = :tenant
|
||||
AND parent_id IN ({placeholders})
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).fetchall()
|
||||
new_ids = [str(r[0]) for r in rows if str(r[0]) not in all_ids]
|
||||
if not new_ids:
|
||||
break
|
||||
all_ids.update(new_ids)
|
||||
frontier = new_ids
|
||||
return all_ids
|
||||
|
||||
|
||||
def perm_menu_ids(conn, menu_ids: Set[str], tenant_no: str) -> List[str]:
|
||||
"""持久化所有带 perms 的菜单(C 页面 :list + F 按钮),与前端 menuCode 过滤对齐。"""
|
||||
if not menu_ids:
|
||||
return []
|
||||
placeholders = ", ".join(f":m{i}" for i in range(len(menu_ids)))
|
||||
params = {f"m{i}": mid for i, mid in enumerate(sorted(menu_ids))}
|
||||
params["tenant"] = tenant_no
|
||||
rows = conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id FROM sys_menu
|
||||
WHERE del_flag = 0 AND tenant_no = :tenant AND id IN ({placeholders})
|
||||
AND perms IS NOT NULL AND TRIM(perms) <> ''
|
||||
ORDER BY id
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).fetchall()
|
||||
return [str(r[0]) for r in rows if r and r[0]]
|
||||
|
||||
|
||||
def leaf_menu_ids(conn, menu_ids: Set[str], tenant_no: str) -> List[str]:
|
||||
"""与角色管理页一致:仅持久化叶子菜单(无子节点的菜单)。"""
|
||||
if not menu_ids:
|
||||
return []
|
||||
placeholders = ", ".join(f":m{i}" for i in range(len(menu_ids)))
|
||||
params = {f"m{i}": mid for i, mid in enumerate(sorted(menu_ids))}
|
||||
params["tenant"] = tenant_no
|
||||
rows = conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT id, parent_id FROM sys_menu
|
||||
WHERE del_flag = 0 AND tenant_no = :tenant AND id IN ({placeholders})
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).mappings().all()
|
||||
id_set = {str(r["id"]) for r in rows}
|
||||
parents_with_children: Set[str] = set()
|
||||
for r in rows:
|
||||
pid = str(r.get("parent_id") or "")
|
||||
if pid in id_set:
|
||||
parents_with_children.add(pid)
|
||||
leaves = sorted(mid for mid in id_set if mid not in parents_with_children)
|
||||
return leaves
|
||||
|
||||
|
||||
def fetch_user_id_map(conn, tenant_no: str) -> Dict[str, str]:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, user_no, dept_id FROM sys_user
|
||||
WHERE tenant_no = :tenant AND del_flag = 0
|
||||
"""
|
||||
),
|
||||
{"tenant": tenant_no},
|
||||
).mappings().all()
|
||||
return {str(r["user_no"]): {"id": str(r["id"]), "dept_id": str(r.get("dept_id") or "")} for r in rows}
|
||||
|
||||
|
||||
def purge_non_super_roles(conn, tenant_no: str, dry_run: bool) -> int:
|
||||
cnt = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COUNT(*) FROM sys_role
|
||||
WHERE tenant_no = :tenant AND del_flag = 0 AND id <> :super_id
|
||||
"""
|
||||
),
|
||||
{"tenant": tenant_no, "super_id": SUPER_ADMIN_ROLE_ID},
|
||||
).scalar()
|
||||
if dry_run:
|
||||
return int(cnt or 0)
|
||||
now = datetime.now()
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE sys_role_menu
|
||||
SET del_flag = 1, update_time = :now
|
||||
WHERE tenant_no = :tenant AND role_id <> :super_id AND del_flag = 0
|
||||
"""
|
||||
),
|
||||
{"tenant": tenant_no, "super_id": SUPER_ADMIN_ROLE_ID, "now": now},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM sys_role_menu
|
||||
WHERE tenant_no = :tenant AND role_id <> :super_id
|
||||
"""
|
||||
),
|
||||
{"tenant": tenant_no, "super_id": SUPER_ADMIN_ROLE_ID},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE sys_role_data_scope_detail
|
||||
SET del_flag = 1, update_time = :now
|
||||
WHERE tenant_no = :tenant AND role_id <> :super_id AND del_flag = 0
|
||||
"""
|
||||
),
|
||||
{"tenant": tenant_no, "super_id": SUPER_ADMIN_ROLE_ID, "now": now},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE sys_user_role
|
||||
SET del_flag = 1, update_time = :now
|
||||
WHERE tenant_no = :tenant AND role_id <> :super_id AND del_flag = 0
|
||||
"""
|
||||
),
|
||||
{"tenant": tenant_no, "super_id": SUPER_ADMIN_ROLE_ID, "now": now},
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE sys_role
|
||||
SET del_flag = 1, update_time = :now, update_by = :admin
|
||||
WHERE tenant_no = :tenant AND del_flag = 0 AND id <> :super_id
|
||||
"""
|
||||
),
|
||||
{"tenant": tenant_no, "super_id": SUPER_ADMIN_ROLE_ID, "now": now, "admin": ADMIN_USER_ID},
|
||||
)
|
||||
return int(cnt or 0)
|
||||
|
||||
|
||||
def ensure_new_roles(conn, tenant_no: str, dry_run: bool) -> None:
|
||||
order = 10
|
||||
for role_name, role_id in NEW_ROLES.items():
|
||||
exists = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM sys_role
|
||||
WHERE id = :id AND tenant_no = :tenant AND del_flag = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"id": role_id, "tenant": tenant_no},
|
||||
).first()
|
||||
if exists:
|
||||
order += 10
|
||||
continue
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_role (
|
||||
id, role_name, data_scope, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :name, '1', '1', :tenant, :order_num,
|
||||
'用户迁移后业务角色初始化', :admin, '', :now, :admin, :now, 0
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": role_id,
|
||||
"name": role_name,
|
||||
"tenant": tenant_no,
|
||||
"order_num": order,
|
||||
"admin": ADMIN_USER_ID,
|
||||
"now": datetime.now(),
|
||||
},
|
||||
)
|
||||
order += 10
|
||||
|
||||
|
||||
def grant_module_menus(conn, role_id: str, leaf_ids: List[str], tenant_no: str, dry_run: bool) -> int:
|
||||
if dry_run:
|
||||
return len(leaf_ids)
|
||||
conn.execute(
|
||||
text("DELETE FROM sys_role_menu WHERE role_id = :rid"),
|
||||
{"rid": role_id},
|
||||
)
|
||||
now = datetime.now()
|
||||
for mid in leaf_ids:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_role_menu (
|
||||
id, role_id, menu_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :role_id, :menu_id, :tenant, 0, 'setup_business_roles',
|
||||
:admin, '', :now, :admin, :now, 0
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": generate_uuid(),
|
||||
"role_id": role_id,
|
||||
"menu_id": mid,
|
||||
"tenant": tenant_no,
|
||||
"admin": ADMIN_USER_ID,
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
return len(leaf_ids)
|
||||
|
||||
|
||||
def assign_user_roles(
|
||||
conn,
|
||||
user_map: Dict[str, dict],
|
||||
tenant_no: str,
|
||||
dry_run: bool,
|
||||
) -> List[dict]:
|
||||
rows_out: List[dict] = []
|
||||
for role_name, user_nos in USER_ROLE_ASSIGNMENTS.items():
|
||||
role_id = NEW_ROLES[role_name]
|
||||
for user_no in user_nos:
|
||||
info = user_map.get(user_no)
|
||||
if not info:
|
||||
raise ValueError(f"用户不存在: {user_no}")
|
||||
exists = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM sys_user_role
|
||||
WHERE user_id = :uid AND role_id = :rid AND tenant_no = :tenant AND del_flag = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"uid": info["id"], "rid": role_id, "tenant": tenant_no},
|
||||
).first()
|
||||
if exists:
|
||||
rows_out.append({"user_no": user_no, "role_name": role_name, "status": "exists"})
|
||||
continue
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO sys_user_role (
|
||||
id, user_id, role_id, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
) VALUES (
|
||||
:id, :uid, :rid, :tenant, 0, 'setup_business_roles',
|
||||
:admin, :dept_id, :now, :admin, :now, 0
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": generate_uuid(),
|
||||
"uid": info["id"],
|
||||
"rid": role_id,
|
||||
"tenant": tenant_no,
|
||||
"admin": ADMIN_USER_ID,
|
||||
"dept_id": info["dept_id"],
|
||||
"now": datetime.now(),
|
||||
},
|
||||
)
|
||||
rows_out.append({"user_no": user_no, "role_name": role_name, "status": "inserted"})
|
||||
return rows_out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="初始化业务角色与菜单权限")
|
||||
parser.add_argument("--env-file", default=".env.production")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument(
|
||||
"--report-out",
|
||||
default=str(BACKEND_ROOT.parent / "docs/data-migration/mappings/role_setup_result.json"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
load_settings_env(args.env_file)
|
||||
settings = load_runtime_settings()
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
|
||||
print("=== setup_business_roles ===")
|
||||
print(f"模式: {'DRY-RUN' if args.dry_run else 'EXECUTE'}")
|
||||
print(f"租户: {TENANT_NO}")
|
||||
print()
|
||||
|
||||
with engine.begin() as conn:
|
||||
removed = purge_non_super_roles(conn, TENANT_NO, args.dry_run)
|
||||
ensure_new_roles(conn, TENANT_NO, args.dry_run)
|
||||
subtree = collect_subtree_menu_ids(conn, MODULE_MENU_ROOTS, TENANT_NO)
|
||||
grant_ids = perm_menu_ids(conn, subtree, TENANT_NO)
|
||||
menu_grants = {}
|
||||
for role_name, role_id in NEW_ROLES.items():
|
||||
menu_grants[role_name] = grant_module_menus(conn, role_id, grant_ids, TENANT_NO, args.dry_run)
|
||||
user_map = fetch_user_id_map(conn, TENANT_NO)
|
||||
assignments = assign_user_roles(conn, user_map, TENANT_NO, args.dry_run)
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"removed_old_roles": removed,
|
||||
"module_menu_roots": MODULE_MENU_ROOTS,
|
||||
"subtree_menu_count": len(subtree),
|
||||
"perm_menu_count": len(grant_ids),
|
||||
"menu_grants_per_role": menu_grants,
|
||||
"user_role_assignments": assignments,
|
||||
"new_roles": NEW_ROLES,
|
||||
}
|
||||
|
||||
if not args.dry_run:
|
||||
Path(args.report_out).write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
print(f"清理旧角色(非超级管理员): {removed}")
|
||||
print(f"模块子树菜单数: {len(subtree)},带 perms 菜单数: {len(grant_ids)}")
|
||||
for role_name, cnt in menu_grants.items():
|
||||
print(f" {role_name}: {cnt} 个菜单权限")
|
||||
print("用户-角色绑定:")
|
||||
for item in assignments:
|
||||
print(f" {item['user_no']} -> {item['role_name']} ({item['status']})")
|
||||
if not args.dry_run:
|
||||
print(f"报告: {args.report_out}")
|
||||
print("完成.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""本地驾驶证图片 → 阿里云 RecognizeDrivingLicense 冒烟测试。
|
||||
|
||||
用法(在 backend 目录):
|
||||
.ocr-venv/bin/python scripts/test_aliyun_driving_license_ocr.py
|
||||
.ocr-venv/bin/python scripts/test_aliyun_driving_license_ocr.py ../驾驶证1.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
from alibabacloud_ocr_api20210707.client import Client as OcrClient
|
||||
from alibabacloud_ocr_api20210707 import models as ocr_models
|
||||
from alibabacloud_tea_openapi import models as open_api_models
|
||||
from alibabacloud_tea_util import models as util_models
|
||||
except ImportError as exc:
|
||||
print('请先安装 SDK: .ocr-venv/bin/pip install alibabacloud_ocr_api20210707 -i https://pypi.tuna.tsinghua.edu.cn/simple')
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_IMAGE = BACKEND_DIR.parent / '驾驶证1.png'
|
||||
MAX_UPLOAD_BYTES = 1_500_000
|
||||
|
||||
|
||||
def load_ocr_settings() -> tuple[str, str, str]:
|
||||
load_dotenv(BACKEND_DIR / '.env')
|
||||
ak = (os.getenv('ALIYUN_OCR_ACCESS_KEY_ID') or '').strip()
|
||||
sk = (os.getenv('ALIYUN_OCR_ACCESS_KEY_SECRET') or '').strip()
|
||||
endpoint = (os.getenv('ALIYUN_OCR_ENDPOINT') or 'ocr-api.cn-hangzhou.aliyuncs.com').strip()
|
||||
if not ak or not sk:
|
||||
raise SystemExit('未配置 ALIYUN_OCR_ACCESS_KEY_ID / ALIYUN_OCR_ACCESS_KEY_SECRET')
|
||||
return ak, sk, endpoint
|
||||
|
||||
|
||||
def compress_image(path: Path) -> bytes:
|
||||
raw = path.read_bytes()
|
||||
if len(raw) <= MAX_UPLOAD_BYTES:
|
||||
print(f'图片大小 {len(raw)} bytes,直接上传')
|
||||
return raw
|
||||
|
||||
print(f'图片较大 ({len(raw)} bytes),压缩到约 {MAX_UPLOAD_BYTES} bytes 以内…')
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
if img.mode not in ('RGB', 'L'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
quality = 85
|
||||
width, height = img.size
|
||||
buf = io.BytesIO()
|
||||
while True:
|
||||
buf.seek(0)
|
||||
buf.truncate(0)
|
||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||
size = buf.tell()
|
||||
if size <= MAX_UPLOAD_BYTES or quality <= 40:
|
||||
print(f'压缩后 {size} bytes (quality={quality}, size={img.size})')
|
||||
return buf.getvalue()
|
||||
quality -= 10
|
||||
if size > MAX_UPLOAD_BYTES * 1.5:
|
||||
width = int(width * 0.85)
|
||||
height = int(height * 0.85)
|
||||
img = img.resize((width, height), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def parse_expire_date(valid_period: str | None) -> str | None:
|
||||
if not valid_period:
|
||||
return None
|
||||
text = str(valid_period).strip()
|
||||
if '长期' in text:
|
||||
return None
|
||||
# 支持: 2017-01-26至2027-01-26 / 2017.01.26-2027.01.26 / 2017.01.26至2027.01.26
|
||||
m = re.search(
|
||||
r'(\d{4})[.\-/年](\d{1,2})[.\-/月](\d{1,2}).*?[至\-—~]\s*(\d{4})[.\-/年](\d{1,2})[.\-/月](\d{1,2})',
|
||||
text,
|
||||
)
|
||||
if m:
|
||||
y, mo, d = m.group(4), m.group(5), m.group(6)
|
||||
return f'{int(y):04d}-{int(mo):02d}-{int(d):02d}'
|
||||
m2 = re.search(r'(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})\s*$', text)
|
||||
if m2:
|
||||
return f'{int(m2.group(1)):04d}-{int(m2.group(2)):02d}-{int(m2.group(3)):02d}'
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
image_path = Path(sys.argv[1]).expanduser().resolve() if len(sys.argv) > 1 else DEFAULT_IMAGE
|
||||
if not image_path.is_file():
|
||||
raise SystemExit(f'图片不存在: {image_path}')
|
||||
|
||||
ak, sk, endpoint = load_ocr_settings()
|
||||
image_bytes = compress_image(image_path)
|
||||
|
||||
client = OcrClient(
|
||||
open_api_models.Config(
|
||||
access_key_id=ak,
|
||||
access_key_secret=sk,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
)
|
||||
|
||||
request = ocr_models.RecognizeDrivingLicenseRequest(body=io.BytesIO(image_bytes))
|
||||
runtime = util_models.RuntimeOptions()
|
||||
print(f'调用 RecognizeDrivingLicense … endpoint={endpoint}')
|
||||
resp = client.recognize_driving_license_with_options(request, runtime)
|
||||
|
||||
body = resp.body
|
||||
if getattr(body, 'code', None):
|
||||
print('识别失败:')
|
||||
print(' code:', body.code)
|
||||
print(' message:', body.message)
|
||||
print(' requestId:', getattr(body, 'request_id', None))
|
||||
raise SystemExit(2)
|
||||
|
||||
raw_data = body.data
|
||||
data = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
|
||||
print('\n===== 原始 Data =====')
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
|
||||
face = (((data or {}).get('data') or {}).get('face') or {}).get('data') or {}
|
||||
cert_no = face.get('licenseNumber')
|
||||
valid_period = face.get('validPeriod')
|
||||
expire_date = parse_expire_date(valid_period)
|
||||
|
||||
print('\n===== 业务回填字段(对应证书表)=====')
|
||||
print('证号 certNo :', cert_no)
|
||||
print('有效期原文 :', valid_period)
|
||||
print('有效期 expireDate:', expire_date)
|
||||
print('姓名 :', face.get('name'))
|
||||
print('准驾类型 :', face.get('approvedType'))
|
||||
print('requestId :', getattr(body, 'request_id', None))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""校验指定用户的 Web menuCodes 是否包含前端侧栏所需的 :list 权限。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
TENANT_NO = "shanxia2.0"
|
||||
|
||||
# 前端侧栏关键 :list(与 sqxp.ts / pl.ts meta.menuCode 对齐)
|
||||
REQUIRED_LIST_CODES = [
|
||||
"crm:clue:list",
|
||||
"crm:customer:list",
|
||||
"crm:customerPool:list",
|
||||
"bx:expenseItem:list",
|
||||
"db:dashboard:list",
|
||||
"pl:plan:list",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--env-file", default=".env.production")
|
||||
parser.add_argument("--user-no", default="方金强")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.env_file:
|
||||
os.environ["ENV_FILE"] = args.env_file
|
||||
from dotenv import load_dotenv
|
||||
|
||||
path = Path(args.env_file)
|
||||
if not path.is_absolute():
|
||||
path = BACKEND_ROOT / path
|
||||
if path.is_file():
|
||||
load_dotenv(path, override=True)
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services.web_auth_roles import iter_user_menu_perm_codes
|
||||
|
||||
engine = create_engine(Settings().DATABASE_URL, pool_pre_ping=True)
|
||||
with Session(engine) as db:
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, nick_name FROM sys_user
|
||||
WHERE tenant_no = :tenant AND user_no = :user_no AND del_flag = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"tenant": TENANT_NO, "user_no": args.user_no},
|
||||
).first()
|
||||
if not row:
|
||||
print(f"用户不存在: {args.user_no}")
|
||||
return 1
|
||||
uid, nick = str(row[0]), row[1]
|
||||
codes = iter_user_menu_perm_codes(db, uid, TENANT_NO)
|
||||
print(f"用户: {args.user_no} ({nick}) id={uid}")
|
||||
print(f"menuCodes 总数: {len(codes)}")
|
||||
list_codes = sorted(c for c in codes if c.endswith(":list"))
|
||||
print(f":list 数量: {len(list_codes)}")
|
||||
missing = [c for c in REQUIRED_LIST_CODES if c not in codes]
|
||||
if missing:
|
||||
print(f"缺少关键 :list ({len(missing)}): {', '.join(missing)}")
|
||||
return 1
|
||||
print("关键 :list 均已具备,前端侧栏应可正常展示(需配合最新 web-admin 构建)。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user