提交
This commit is contained in:
1 parent
70bdd820ce
commit
bede140d12
988 files changed
+692310
-775910
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
65566
|
||||
87807
|
||||
@@ -591,6 +591,7 @@ def product_list(
|
||||
keyword=body.keyword,
|
||||
category_id=body.category_id,
|
||||
status=body.status,
|
||||
supplier_id=body.supplier_id,
|
||||
user_info=user_info,
|
||||
)
|
||||
tp = total_pages(total, body.pageSize)
|
||||
@@ -860,6 +861,7 @@ def product_export(
|
||||
keyword=body.keyword,
|
||||
category_id=body.category_id,
|
||||
status=body.status,
|
||||
supplier_id=body.supplier_id,
|
||||
)
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
return export_rows_to_xlsx_response(
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.crud.crm.contract_product import (
|
||||
)
|
||||
from app.crud.crm.customer import get_customer
|
||||
from app.crud.crm.invoice_info import list_invoice_info_by_customer
|
||||
from app.services.crm_supplier_hazard import assert_products_allowed_for_supplier
|
||||
from app.crud.crm.product import resolve_product_id_for_quote_item
|
||||
from app.crud.crm.quote import get_quote
|
||||
from app.crud.crm.quote_product import list_quote_products
|
||||
@@ -915,6 +916,14 @@ def create_contract(
|
||||
|
||||
supplier_id = (_get_field(data, "supplier_id", "supplierId") or "").strip() or None
|
||||
supplier_short_name = _get_supplier_short_name(db, supplier_id or "", tenant_no)
|
||||
|
||||
assert_products_allowed_for_supplier(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
supplier_id=str(supplier_id) if supplier_id else None,
|
||||
products=product_rows or [],
|
||||
)
|
||||
|
||||
contract_type = (_get_field(data, "contract_type", "contractType") or "sale").strip() or "sale"
|
||||
sign_date = _parse_optional_date(_get_field(data, "sign_date", "signDate")) or date.today()
|
||||
|
||||
@@ -1013,6 +1022,18 @@ def update_contract(
|
||||
old, data, db=db, tenant_no=tenant_no, user_id=user_id, is_create=False
|
||||
)
|
||||
|
||||
products_to_check = (
|
||||
product_rows
|
||||
if product_rows is not None
|
||||
else list_contract_products(db, contract_id, tenant_no=tenant_no)
|
||||
)
|
||||
assert_products_allowed_for_supplier(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
supplier_id=str(old.supplier_id) if old.supplier_id else None,
|
||||
products=products_to_check or [],
|
||||
)
|
||||
|
||||
if product_rows is not None:
|
||||
_resolve_contract_product_ids(db, tenant_no=tenant_no, products=product_rows, user_id=user_id)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.crm.product import get_product
|
||||
from app.models.crm_models import CrmContractProduct, CrmContractProductPriceTier
|
||||
from app.services.crm_supplier_hazard import enrich_products_with_hazard_flags
|
||||
|
||||
_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_DATE_FMT = "%Y-%m-%d"
|
||||
@@ -110,6 +111,7 @@ def list_contract_products(
|
||||
for item in result:
|
||||
item["priceTiers"] = tier_map.get(item["id"], [])
|
||||
|
||||
enrich_products_with_hazard_flags(db, result)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -322,6 +322,7 @@ def list_products(
|
||||
keyword: str = None,
|
||||
category_id: str = None,
|
||||
status: str = None,
|
||||
supplier_id: str = None,
|
||||
user_info=None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""分页查询产品列表"""
|
||||
@@ -346,6 +347,24 @@ def list_products(
|
||||
if status:
|
||||
q = q.filter(CrmProduct.status == status)
|
||||
|
||||
if supplier_id:
|
||||
from app.services.crm_supplier_hazard import get_supplier_hazard_flags
|
||||
|
||||
allow_toxic, allow_explosive = get_supplier_hazard_flags(db, supplier_id, tenant_no=tenant_no)
|
||||
conds = [
|
||||
and_(
|
||||
or_(CrmProduct.is_toxic.is_(None), CrmProduct.is_toxic != 1),
|
||||
or_(CrmProduct.is_explosive.is_(None), CrmProduct.is_explosive != 1),
|
||||
)
|
||||
]
|
||||
if allow_toxic == 1:
|
||||
conds.append(and_(CrmProduct.is_toxic == 1, or_(CrmProduct.is_explosive.is_(None), CrmProduct.is_explosive != 1)))
|
||||
if allow_explosive == 1:
|
||||
conds.append(and_(CrmProduct.is_explosive == 1, or_(CrmProduct.is_toxic.is_(None), CrmProduct.is_toxic != 1)))
|
||||
if allow_toxic == 1 and allow_explosive == 1:
|
||||
conds.append(and_(CrmProduct.is_toxic == 1, CrmProduct.is_explosive == 1))
|
||||
q = q.filter(or_(*conds))
|
||||
|
||||
total = q.order_by(None).with_entities(func.count(CrmProduct.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.crud.crm.contact import get_contact
|
||||
from app.crud.crm.customer import get_customer
|
||||
from app.crud.crm.opportunity import get_opportunity
|
||||
from app.crud.crm.product import resolve_product_id_for_quote_item
|
||||
from app.services.crm_supplier_hazard import assert_products_allowed_for_supplier
|
||||
from app.crud.crm.quote_approval import (
|
||||
can_user_approve,
|
||||
create_approval_records,
|
||||
@@ -582,6 +583,13 @@ def create_quote(
|
||||
supplier_id = (_get_field(data, "supplier_id", "supplierId") or "").strip() or None
|
||||
supplier_short_name = _get_supplier_short_name(db, supplier_id or "", tenant_no)
|
||||
|
||||
assert_products_allowed_for_supplier(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
supplier_id=str(supplier_id) if supplier_id else None,
|
||||
products=product_rows or [],
|
||||
)
|
||||
|
||||
quote_date = _parse_optional_date(_get_field(data, "quote_date", "quoteDate")) or date.today()
|
||||
quote_type = (_get_field(data, "quote_type", "quoteType") or "sales").strip() or "sales"
|
||||
validity_days = int(_get_field(data, "validity_days", "validityDays") or 30)
|
||||
@@ -762,6 +770,18 @@ def update_quote(
|
||||
if supplier_changed:
|
||||
old.template_content = None
|
||||
|
||||
products_to_check = (
|
||||
product_rows
|
||||
if product_rows is not None
|
||||
else list_quote_products(db, quote_id, tenant_no=tenant_no)
|
||||
)
|
||||
assert_products_allowed_for_supplier(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
supplier_id=str(old.supplier_id) if old.supplier_id else None,
|
||||
products=products_to_check or [],
|
||||
)
|
||||
|
||||
if product_rows is not None:
|
||||
_resolve_quote_product_ids(db, tenant_no=tenant_no, products=product_rows, user_id=user_id)
|
||||
calculate_quote_amount(old, product_rows)
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.core.delete_guard import assert_row_deletable_by_creator
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.crm.product import get_product
|
||||
from app.models.crm_models import CrmQuoteProduct
|
||||
from app.services.crm_supplier_hazard import enrich_products_with_hazard_flags
|
||||
|
||||
_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
@@ -96,7 +97,9 @@ def list_quote_products(
|
||||
if tenant_no:
|
||||
q = q.filter(CrmQuoteProduct.tenant_no == tenant_no)
|
||||
rows = q.order_by(CrmQuoteProduct.sort.asc(), CrmQuoteProduct.create_time.asc()).all()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
result = [_row_to_dict(r) for r in rows]
|
||||
enrich_products_with_hazard_flags(db, result)
|
||||
return result
|
||||
|
||||
|
||||
def get_quote_product(db: Session, product_line_id: str) -> Optional[CrmQuoteProduct]:
|
||||
|
||||
@@ -27,6 +27,8 @@ _WRITABLE_FIELDS = (
|
||||
"phone_fax",
|
||||
"bank_name",
|
||||
"bank_account",
|
||||
"allow_toxic",
|
||||
"allow_explosive",
|
||||
"status",
|
||||
"order_num",
|
||||
"remark",
|
||||
@@ -52,6 +54,8 @@ def _row_to_dict(row: CrmSupplierInfo) -> Dict[str, Any]:
|
||||
"phoneFax": row.phone_fax or "",
|
||||
"bankName": row.bank_name or "",
|
||||
"bankAccount": row.bank_account or "",
|
||||
"allowToxic": int(row.allow_toxic or 0),
|
||||
"allowExplosive": int(row.allow_explosive or 0),
|
||||
"status": row.status or "0",
|
||||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||||
"remark": row.remark or "",
|
||||
@@ -159,6 +163,8 @@ def create_supplier_info(
|
||||
phone_fax=kwargs.get("phone_fax") or "",
|
||||
bank_name=kwargs.get("bank_name") or "",
|
||||
bank_account=kwargs.get("bank_account") or "",
|
||||
allow_toxic=kwargs.get("allow_toxic", 0) or 0,
|
||||
allow_explosive=kwargs.get("allow_explosive", 0) or 0,
|
||||
status=kwargs.get("status") or "0",
|
||||
tenant_no=tenant_no,
|
||||
order_num=kwargs.get("order_num"),
|
||||
|
||||
@@ -974,6 +974,8 @@ class CrmSupplierInfo(Base):
|
||||
phone_fax = Column(String(100), nullable=True, comment="电话/传真")
|
||||
bank_name = Column(String(200), nullable=True, comment="开户银行")
|
||||
bank_account = Column(String(100), nullable=True, comment="账号")
|
||||
allow_toxic = Column(SmallInteger, nullable=False, default=0, comment="是否允许易制毒产品(0否1是)")
|
||||
allow_explosive = Column(SmallInteger, nullable=False, default=0, comment="是否允许易制爆产品(0否1是)")
|
||||
status = Column(String(1), nullable=True, default="0", comment="状态(0正常 1停用)")
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
|
||||
@@ -73,6 +73,7 @@ class ProductListBody(BaseModel):
|
||||
keyword: Optional[str] = Field(default=None, description="搜索关键词(产品名称/编号)")
|
||||
category_id: Optional[str] = Field(default=None, alias="categoryId", description="分类ID")
|
||||
status: Optional[str] = Field(default=None, description="状态")
|
||||
supplier_id: Optional[str] = Field(default=None, alias="supplierId", description="报价/合同主体ID,传入则按危化品能力过滤")
|
||||
|
||||
|
||||
class ProductCreateBody(BaseModel):
|
||||
|
||||
@@ -41,6 +41,8 @@ class SupplierInfoCreateBody(BaseModel):
|
||||
phone_fax: Optional[str] = Field(default=None, alias="phoneFax", description="电话/传真")
|
||||
bank_name: Optional[str] = Field(default=None, alias="bankName", description="开户银行")
|
||||
bank_account: Optional[str] = Field(default=None, alias="bankAccount", description="账号")
|
||||
allow_toxic: Optional[int] = Field(default=0, alias="allowToxic", description="是否允许易制毒(0/1)")
|
||||
allow_explosive: Optional[int] = Field(default=0, alias="allowExplosive", description="是否允许易制爆(0/1)")
|
||||
status: Optional[str] = Field(default="0", description="状态(0正常 1停用)")
|
||||
order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号")
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
@@ -59,6 +61,8 @@ class SupplierInfoUpdateBody(BaseModel):
|
||||
phone_fax: Optional[str] = Field(default=None, alias="phoneFax", description="电话/传真")
|
||||
bank_name: Optional[str] = Field(default=None, alias="bankName", description="开户银行")
|
||||
bank_account: Optional[str] = Field(default=None, alias="bankAccount", description="账号")
|
||||
allow_toxic: Optional[int] = Field(default=None, alias="allowToxic", description="是否允许易制毒(0/1)")
|
||||
allow_explosive: Optional[int] = Field(default=None, alias="allowExplosive", description="是否允许易制爆(0/1)")
|
||||
status: Optional[str] = Field(default=None, description="状态")
|
||||
order_num: Optional[int] = Field(default=None, alias="orderNum", description="排序号")
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""CRM 供应商危化品经营能力判定"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.crm.product import get_product
|
||||
from app.crud.crm.supplier_info import get_supplier_info
|
||||
|
||||
|
||||
def product_allowed(
|
||||
is_toxic,
|
||||
is_explosive,
|
||||
*,
|
||||
allow_toxic,
|
||||
allow_explosive,
|
||||
) -> bool:
|
||||
toxic = int(is_toxic or 0)
|
||||
explosive = int(is_explosive or 0)
|
||||
allow_t = int(allow_toxic or 0)
|
||||
allow_e = int(allow_explosive or 0)
|
||||
|
||||
if not toxic and not explosive:
|
||||
return True
|
||||
if toxic and not explosive:
|
||||
return bool(allow_t)
|
||||
if explosive and not toxic:
|
||||
return bool(allow_e)
|
||||
return bool(allow_t) and bool(allow_e)
|
||||
|
||||
|
||||
def get_supplier_hazard_flags(
|
||||
db: Session,
|
||||
supplier_id: Optional[str],
|
||||
*,
|
||||
tenant_no: str,
|
||||
) -> Tuple[int, int]:
|
||||
if not supplier_id:
|
||||
return (0, 0)
|
||||
row = get_supplier_info(db, supplier_id, tenant_no=tenant_no)
|
||||
if not row:
|
||||
return (0, 0)
|
||||
return (
|
||||
int(getattr(row, "allow_toxic", 0) or 0),
|
||||
int(getattr(row, "allow_explosive", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
def enrich_products_with_hazard_flags(
|
||||
db: Session,
|
||||
items: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""批量回填明细行 isToxic/isExplosive(来自产品主数据,供前端切换主体剔除)。"""
|
||||
from app.models.crm_models import CrmProduct
|
||||
|
||||
pids = []
|
||||
for item in items:
|
||||
pid = item.get("productId") or item.get("product_id")
|
||||
if pid:
|
||||
pids.append(str(pid))
|
||||
if not pids:
|
||||
for item in items:
|
||||
item.setdefault("isToxic", 0)
|
||||
item.setdefault("isExplosive", 0)
|
||||
return
|
||||
|
||||
rows = (
|
||||
db.query(CrmProduct.id, CrmProduct.is_toxic, CrmProduct.is_explosive)
|
||||
.filter(CrmProduct.id.in_(pids), CrmProduct.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
flag_map = {
|
||||
str(r.id): (int(r.is_toxic or 0), int(r.is_explosive or 0)) for r in rows
|
||||
}
|
||||
for item in items:
|
||||
pid = item.get("productId") or item.get("product_id")
|
||||
toxic, explosive = flag_map.get(str(pid) if pid else "", (0, 0))
|
||||
item["isToxic"] = toxic
|
||||
item["isExplosive"] = explosive
|
||||
|
||||
|
||||
def assert_products_allowed_for_supplier(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
supplier_id: Optional[str],
|
||||
products: Iterable[Any],
|
||||
) -> None:
|
||||
allow_toxic, allow_explosive = get_supplier_hazard_flags(
|
||||
db, supplier_id, tenant_no=tenant_no
|
||||
)
|
||||
blocked_names: List[str] = []
|
||||
|
||||
for item in products:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
pid = item.get("productId") or item.get("product_id")
|
||||
if not pid:
|
||||
continue
|
||||
prod = get_product(db, str(pid))
|
||||
if not prod:
|
||||
continue
|
||||
is_toxic = int(getattr(prod, "is_toxic", 0) or 0)
|
||||
is_explosive = int(getattr(prod, "is_explosive", 0) or 0)
|
||||
if not product_allowed(
|
||||
is_toxic,
|
||||
is_explosive,
|
||||
allow_toxic=allow_toxic,
|
||||
allow_explosive=allow_explosive,
|
||||
):
|
||||
name = getattr(prod, "product_name", None) or str(pid)
|
||||
blocked_names.append(str(name))
|
||||
|
||||
if blocked_names:
|
||||
raise ValueError(
|
||||
"当前主体不具备以下产品的危化品经营能力,请移除后重试:"
|
||||
+ "、".join(blocked_names)
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
backend/graphify-out/cache/ast/059750fc990d611ab83d9bd31cc686d83922a6bb40972b00344340f53d607cfa.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_api_crm_py", "label": "crm.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L1"}, {"id": "api_crm_category_tree", "label": "category_tree()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L328"}, {"id": "api_crm_customer_list", "label": "customer_list()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L356"}, {"id": "api_crm_customer_detail", "label": "customer_detail()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L390"}, {"id": "api_crm_customer_create", "label": "customer_create()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L417"}, {"id": "api_crm_customer_update", "label": "customer_update()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L447"}, {"id": "api_crm_customer_delete", "label": "customer_delete()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L474"}, {"id": "api_crm_customer_generate_no", "label": "customer_generate_no()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L502"}, {"id": "api_crm_category_add", "label": "category_add()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L518"}, {"id": "api_crm_category_update", "label": "category_update()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L537"}, {"id": "api_crm_category_delete", "label": "category_delete()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L557"}, {"id": "api_crm_product_list", "label": "product_list()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L577"}, {"id": "api_crm_product_detail", "label": "product_detail()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L614"}, {"id": "api_crm_product_add", "label": "product_add()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L635"}, {"id": "api_crm_product_update", "label": "product_update()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L659"}, {"id": "api_crm_product_delete", "label": "product_delete()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L680"}, {"id": "api_crm_product_update_status", "label": "product_update_status()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L708"}, {"id": "api_crm_product_options", "label": "product_options()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L729"}, {"id": "api_crm_product_import", "label": "product_import()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L756"}, {"id": "api_crm_product_export", "label": "product_export()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L847"}, {"id": "api_crm_product_import_template", "label": "product_import_template()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L882"}, {"id": "api_crm_customer_transfer", "label": "customer_transfer()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L926"}, {"id": "api_crm_customer_into_pool", "label": "customer_into_pool()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L952"}, {"id": "api_crm_customer_deal", "label": "customer_deal()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L977"}, {"id": "api_crm_customer_check_duplicate", "label": "customer_check_duplicate()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L1006"}, {"id": "api_crm_customer_export", "label": "customer_export()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/crm.py", "source_location": "L1029"}, {"id": "api_crm_customer_impLine truncated
|
||||
backend/graphify-out/cache/ast/0db65dc4fda82f1df5bbbe64394d3df670c020d65653089a1da2b0da9f24a649.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_models_crm_models_py", "label": "crm_models.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L1"}, {"id": "models_crm_models_crmproductcategory", "label": "CrmProductCategory", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L21"}, {"id": "base", "label": "Base", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "models_crm_models_crmproduct", "label": "CrmProduct", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L43"}, {"id": "models_crm_models_sysoss", "label": "SysOss", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L78"}, {"id": "models_crm_models_crmcustomer", "label": "CrmCustomer", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L110"}, {"id": "models_crm_models_crmcontact", "label": "CrmContact", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L193"}, {"id": "models_crm_models_crminvoiceinfo", "label": "CrmInvoiceInfo", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L228"}, {"id": "models_crm_models_crmcustomerfollow", "label": "CrmCustomerFollow", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L256"}, {"id": "models_crm_models_crmcustomerpool", "label": "CrmCustomerPool", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L287"}, {"id": "models_crm_models_crmcustomerpoolrule", "label": "CrmCustomerPoolRule", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L313"}, {"id": "models_crm_models_crmopportunity", "label": "CrmOpportunity", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L336"}, {"id": "models_crm_models_crmopportunityfollow", "label": "CrmOpportunityFollow", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L379"}, {"id": "models_crm_models_crmopportunityproduct", "label": "CrmOpportunityProduct", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L410"}, {"id": "models_crm_models_crmopportunitystagelog", "label": "CrmOpportunityStageLog", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L440"}, {"id": "models_crm_models_crmcontract", "label": "CrmContract", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L471"}, {"id": "models_crm_models_crmcontractapproval", "label": "CrmContractApproval", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L554"}, {"id": "models_crm_models_crmcontractchange", "label": "CrmContractChange", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L585"}, {"id": "models_crm_models_crmcontracttemplate", "label": "CrmContractTemplate", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L616"}, {"id": "models_crm_models_crmcontractproduct", "label": "CrmContractProduct", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L650"}, {"id": "models_crm_models_crmcontractproductpricetier", "label": "CrmContractProductPriceTier", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L688"}, {"id": "models_crm_models_crmquote", "label": "CrmQuote", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L713"}, {"id": "models_crm_models_crmquoteproduct", "label": "CrmQuoteProduct", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L771"}, {"id": "models_crm_models_crmquoteapproval", "label": "CrmQuoteApproval", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/models/crm_models.py", "source_location": "L806"}, {"id": "models_crm_models_crmquotesharelog", "label": "CrmQuoteShareLogLine truncated
|
||||
backend/graphify-out/cache/ast/2365e02c6f2584dd6d18e1ad3c8d3075096777b244cc1430ef913254c61a9442.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "label": "crm_supplier_hazard.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L1"}, {"id": "services_crm_supplier_hazard_product_allowed", "label": "product_allowed()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L12"}, {"id": "services_crm_supplier_hazard_get_supplier_hazard_flags", "label": "get_supplier_hazard_flags()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L33"}, {"id": "services_crm_supplier_hazard_enrich_products_with_hazard_flags", "label": "enrich_products_with_hazard_flags()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L50"}, {"id": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "label": "assert_products_allowed_for_supplier()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L83"}, {"id": "services_crm_supplier_hazard_rationale_54", "label": "\u6279\u91cf\u56de\u586b\u660e\u7ec6\u884c isToxic/isExplosive\uff08\u6765\u81ea\u4ea7\u54c1\u4e3b\u6570\u636e\uff0c\u4f9b\u524d\u7aef\u5207\u6362\u4e3b\u4f53\u5254\u9664\uff09\u3002", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L54"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "typing", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L4", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "sqlalchemy_orm", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L6", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "app_crud_crm_product", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L8", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "app_crud_crm_supplier_info", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L9", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "services_crm_supplier_hazard_product_allowed", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L12", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "services_crm_supplier_hazard_get_supplier_hazard_flags", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L33", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "services_crm_supplier_hazard_enrich_products_with_hazard_flags", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L50", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L83", "weight": 1.0}, {"source": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "target": "services_crm_supplier_hazard_get_supplier_hazard_flags", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L90", "weight": 1.0}, {"source": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "target": "services_crm_supplier_hazard_product_allowed", "relation": "calls", "context": "call", "confideLine truncated
|
||||
backend/graphify-out/cache/ast/25bc81362d6365c8bd852892890f3ff0c74fd702016cb21104c3bdfa51e2f858.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_supplier_info_py", "label": "supplier_info.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L1"}, {"id": "crm_supplier_info_supplierinfolistbody", "label": "SupplierInfoListBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L8"}, {"id": "basemodel", "label": "BaseModel", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "crm_supplier_info_supplierinfolistallbody", "label": "SupplierInfoListAllBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L19"}, {"id": "crm_supplier_info_supplierinfodetailbody", "label": "SupplierInfoDetailBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L28"}, {"id": "crm_supplier_info_supplierinfocreatebody", "label": "SupplierInfoCreateBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L32"}, {"id": "crm_supplier_info_supplierinfoupdatebody", "label": "SupplierInfoUpdateBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L51"}, {"id": "crm_supplier_info_supplierinfodeletebody", "label": "SupplierInfoDeleteBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L71"}, {"id": "crm_supplier_info_rationale_1", "label": "CRM \u4f9b\u65b9\u4fe1\u606f Pydantic Schemas", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L1"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_supplier_info_py", "target": "typing", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L3", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_supplier_info_py", "target": "pydantic", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L5", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_supplier_info_py", "target": "crm_supplier_info_supplierinfolistbody", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L8", "weight": 1.0}, {"source": "crm_supplier_info_supplierinfolistbody", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L8", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_supplier_info_py", "target": "crm_supplier_info_supplierinfolistallbody", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L19", "weight": 1.0}, {"source": "crm_supplier_info_supplierinfolistallbody", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L19", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_supplier_info_py", "target": "crm_supplier_info_supplierinfodetailbody", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L28", "weight": 1.0}, {"source": "crm_supplier_info_supplierinfodetailbody", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L28", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_supplier_info_py", "target": "crm_supplier_info_supplierinfocreatebody", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L32", "weight": 1.0}, {"source": "crm_supplier_info_supplierinfocreatebody", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/supplier_info.py", "source_location": "L32", "weigLine truncated
|
||||
backend/graphify-out/cache/ast/2c244898e16084f533aa42a0a3b45106a99515ebf627d37557b22549fd1f0035.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_supplier_info_py", "label": "supplier_info.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L1"}, {"id": "crm_supplier_info_total_pages", "label": "total_pages()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L38"}, {"id": "crm_supplier_info_row_to_dict", "label": "_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L44"}, {"id": "crm_supplier_info_apply_list_filters", "label": "_apply_list_filters()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L69"}, {"id": "crm_supplier_info_list_supplier_info", "label": "list_supplier_info()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L78"}, {"id": "crm_supplier_info_list_all_supplier_info", "label": "list_all_supplier_info()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L106"}, {"id": "crm_supplier_info_get_supplier_info", "label": "get_supplier_info()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L126"}, {"id": "crm_supplier_info_create_supplier_info", "label": "create_supplier_info()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L142"}, {"id": "crm_supplier_info_update_supplier_info", "label": "update_supplier_info()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L182"}, {"id": "crm_supplier_info_soft_delete_supplier_info", "label": "soft_delete_supplier_info()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L218"}, {"id": "crm_supplier_info_rationale_1", "label": "CRM \u4f9b\u65b9\u4fe1\u606f CRUD \u2014 \u5bf9\u7167 CrmSupplierInfoServiceImpl.java", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L1"}, {"id": "crm_supplier_info_rationale_45", "label": "ORM \u2192 camelCase\uff08\u5355\u70b9\u771f\u76f8\uff09", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L45"}, {"id": "crm_supplier_info_rationale_70", "label": "Java: buildQueryWrapper", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L70"}, {"id": "crm_supplier_info_rationale_114", "label": "Java: queryList\uff08\u4e0d\u5206\u9875\uff0c\u62a5\u4ef7\u5355\u4e0b\u62c9\uff09", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L114"}, {"id": "crm_supplier_info_rationale_226", "label": "Java: deleteWithValidByIds\uff08\u95e8\u6237\u8f6f\u5220\uff09", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L226"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_supplier_info_py", "target": "math", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L4", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_supplier_info_py", "target": "datetime", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L5", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_supplier_info_py", "target": "typing", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L6", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_supplier_info_py", "target": "sqlalchemy_orm", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/supplier_info.py", "source_location": "L8", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_supplier_info_py", "target": "app_core_delete_guard", "relation": "imports_from", "context": "import", "confidence": "EXTRACTLine truncated
|
||||
backend/graphify-out/cache/ast/8db06b77841cc1ed8c140128ed08d8cb29e0cdad9178902ec829048577867667.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "label": "crm_supplier_hazard.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L1"}, {"id": "services_crm_supplier_hazard_product_allowed", "label": "product_allowed()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L12"}, {"id": "services_crm_supplier_hazard_get_supplier_hazard_flags", "label": "get_supplier_hazard_flags()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L33"}, {"id": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "label": "assert_products_allowed_for_supplier()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L50"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "typing", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L4", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "sqlalchemy_orm", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L6", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "app_crud_crm_product", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L8", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "app_crud_crm_supplier_info", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L9", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "services_crm_supplier_hazard_product_allowed", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L12", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "services_crm_supplier_hazard_get_supplier_hazard_flags", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L33", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_services_crm_supplier_hazard_py", "target": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L50", "weight": 1.0}, {"source": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "target": "services_crm_supplier_hazard_get_supplier_hazard_flags", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L57", "weight": 1.0}, {"source": "services_crm_supplier_hazard_assert_products_allowed_for_supplier", "target": "services_crm_supplier_hazard_product_allowed", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L73", "weight": 1.0}], "raw_calls": [{"caller_nid": "services_crm_supplier_hazard_product_allowed", "callee": "int", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L19"}, {"caller_nid": "services_crm_supplier_hazard_product_allowed", "callee": "int", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L20"}, {"caller_nid": "services_crm_supplier_hazard_product_allowed", "callee": "int", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/services/crm_supplier_hazard.py", "source_location": "L21"}, {"caller_nid": "services_crm_supplier_hazard_product_allowed", "callee": "int", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backeLine truncated
|
||||
backend/graphify-out/cache/ast/a70b3c47ead7b3b0e94d3f24ec5b1ca4f2e3660e36cf3f6bab760c792b223d84.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_contract_py", "label": "contract.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L1"}, {"id": "crm_contract_ensure_contract_in_scope", "label": "_ensure_contract_in_scope()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L49"}, {"id": "crm_contract_get_contract_row_in_scope", "label": "_get_contract_row_in_scope()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L53"}, {"id": "crm_contract_total_pages", "label": "total_pages()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L70"}, {"id": "crm_contract_decimal_value", "label": "_decimal_value()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L76"}, {"id": "crm_contract_parse_optional_date", "label": "_parse_optional_date()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L84"}, {"id": "crm_contract_parse_optional_datetime", "label": "_parse_optional_datetime()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L102"}, {"id": "crm_contract_get_field", "label": "_get_field()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L118"}, {"id": "crm_contract_resolve_user_display_name", "label": "_resolve_user_display_name()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L125"}, {"id": "crm_contract_is_blank", "label": "_is_blank()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L135"}, {"id": "crm_contract_first_non_blank", "label": "_first_non_blank()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L139"}, {"id": "crm_contract_get_default_invoice_info", "label": "_get_default_invoice_info()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L147"}, {"id": "crm_contract_build_customer_address", "label": "_build_customer_address()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L157"}, {"id": "crm_contract_row_to_dict", "label": "_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L169"}, {"id": "crm_contract_contract_row_to_dict", "label": "contract_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L246"}, {"id": "crm_contract_build_contract_filters", "label": "_build_contract_filters()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L250"}, {"id": "crm_contract_list_contracts", "label": "list_contracts()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L391"}, {"id": "crm_contract_get_contract", "label": "get_contract()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L425"}, {"id": "crm_contract_get_contract_detail", "label": "get_contract_detail()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L530"}, {"id": "crm_contract_get_contract_row", "label": "get_contract_row()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L550"}, {"id": "crm_contract_get_supplier_short_name", "label": "_get_supplier_short_name()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L567"}, {"id": "crm_contract_generate_contract_no", "label": "generate_contract_no()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L580"}, {"id": "crm_contract_generate_contract_name", "label": "generate_contract_name()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py", "source_location": "L629"}, {"id": "crm_contract_validate_contract_type_and_products", "label": "_validate_contract_type_and_products()", "file_type": Line truncated
|
||||
backend/graphify-out/cache/ast/c2fcddadcf54720eb3a12beb5732f65161bd29a4b42ede2cdc7b7868edfaeddb.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_product_py", "label": "product.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L1"}, {"id": "crm_product_productcategorycreatebody", "label": "ProductCategoryCreateBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L12"}, {"id": "basemodel", "label": "BaseModel", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "crm_product_productcategoryupdatebody", "label": "ProductCategoryUpdateBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L26"}, {"id": "crm_product_categorydeletebody", "label": "CategoryDeleteBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L41"}, {"id": "crm_product_productcategorytreebody", "label": "ProductCategoryTreeBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L47"}, {"id": "crm_product_productupdatestatusbody", "label": "ProductUpdateStatusBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L56"}, {"id": "crm_product_productlistbody", "label": "ProductListBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L66"}, {"id": "crm_product_productcreatebody", "label": "ProductCreateBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L79"}, {"id": "crm_product_productupdatebody", "label": "ProductUpdateBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L111"}, {"id": "crm_product_productdetailbody", "label": "ProductDetailBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L144"}, {"id": "crm_product_productdeletebody", "label": "ProductDeleteBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L150"}, {"id": "crm_product_productoptionsbody", "label": "ProductOptionsBody", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L157"}, {"id": "crm_product_rationale_1", "label": "CRM \u4ea7\u54c1\u6a21\u5757 Pydantic Schemas", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L1"}, {"id": "crm_product_rationale_48", "label": "\u4ea7\u54c1\u5206\u7c7b\u6811\u67e5\u8be2\uff08\u5bc6\u6587\u89e3\u5bc6\u540e\u987b\u7528 Pydantic \u7ed1\u5b9a\uff0c\u88f8 dict=None \u5728\u90e8\u5206\u94fe\u8def\u4e0b\u4f1a\u4e22 body\uff09", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L48"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_product_py", "target": "decimal", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L3", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_product_py", "target": "typing", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L4", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_product_py", "target": "pydantic", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L6", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_product_py", "target": "crm_product_productcategorycreatebody", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L12", "weight": 1.0}, {"source": "crm_product_productcategorycreatebody", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/crm/product.py", "source_location": "L12", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_schemas_crm_product_py", "target": "crm_product_productcategoryupdatebody", "relation": "contains", "confidence": "EXTRACTED", "source_fiLine truncated
|
||||
backend/graphify-out/cache/ast/ce8bd619b1a6dc746541a57eccc095d19ccde3bdb88341995631e15fcfb14bca.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_tests_test_crm_supplier_hazard_py", "label": "test_crm_supplier_hazard.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L1"}, {"id": "tests_test_crm_supplier_hazard_test_plain_always_allowed", "label": "test_plain_always_allowed()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L4"}, {"id": "tests_test_crm_supplier_hazard_test_toxic_only_requires_allow_toxic", "label": "test_toxic_only_requires_allow_toxic()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L8"}, {"id": "tests_test_crm_supplier_hazard_test_explosive_only_requires_allow_explosive", "label": "test_explosive_only_requires_allow_explosive()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L13"}, {"id": "tests_test_crm_supplier_hazard_test_dual_requires_both", "label": "test_dual_requires_both()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L18"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_tests_test_crm_supplier_hazard_py", "target": "app_services_crm_supplier_hazard", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L1", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_tests_test_crm_supplier_hazard_py", "target": "tests_test_crm_supplier_hazard_test_plain_always_allowed", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L4", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_tests_test_crm_supplier_hazard_py", "target": "tests_test_crm_supplier_hazard_test_toxic_only_requires_allow_toxic", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L8", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_tests_test_crm_supplier_hazard_py", "target": "tests_test_crm_supplier_hazard_test_explosive_only_requires_allow_explosive", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L13", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_tests_test_crm_supplier_hazard_py", "target": "tests_test_crm_supplier_hazard_test_dual_requires_both", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L18", "weight": 1.0}], "raw_calls": [{"caller_nid": "tests_test_crm_supplier_hazard_test_plain_always_allowed", "callee": "product_allowed", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L5"}, {"caller_nid": "tests_test_crm_supplier_hazard_test_toxic_only_requires_allow_toxic", "callee": "product_allowed", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L9"}, {"caller_nid": "tests_test_crm_supplier_hazard_test_toxic_only_requires_allow_toxic", "callee": "product_allowed", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L10"}, {"caller_nid": "tests_test_crm_supplier_hazard_test_explosive_only_requires_allow_explosive", "callee": "product_allowed", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L14"}, {"caller_nid": "tests_test_crm_supplier_hazard_test_explosive_only_requires_allow_explosive", "callee": "product_allowed", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L15"}, {"caller_nid": "tests_test_crm_supplier_hazard_test_dual_requires_both", "callee": "product_allowed", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L19"}, {"caller_nid": "tests_test_crm_supplier_hazard_test_dual_requires_both", "callee": "product_allowed", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_crm_supplier_hazard.py", "source_location": "L20"}, {"caller_nid": "tests_test_crm_supplier_hazard_test_dual_requirLine truncated
|
||||
backend/graphify-out/cache/ast/daf0377594d3a518318ae61b5a6c4694bb6b666f4d48cdc3a04bdb9587ca3563.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_contract_product_py", "label": "contract_product.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L1"}, {"id": "crm_contract_product_row_to_dict", "label": "_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L20"}, {"id": "crm_contract_product_tier_row_to_dict", "label": "_tier_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L50"}, {"id": "crm_contract_product_list_contract_products", "label": "list_contract_products()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L74"}, {"id": "crm_contract_product_decimal_value", "label": "_decimal_value()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L118"}, {"id": "crm_contract_product_get_field", "label": "_get_field()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L126"}, {"id": "crm_contract_product_parse_optional_date", "label": "_parse_optional_date()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L133"}, {"id": "crm_contract_product_calculate_subtotal", "label": "calculate_subtotal()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L151"}, {"id": "crm_contract_product_letter_oss_ids_to_json", "label": "_letter_oss_ids_to_json()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L160"}, {"id": "crm_contract_product_insert_contract_product", "label": "insert_contract_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L170"}, {"id": "crm_contract_product_batch_insert_contract_products", "label": "batch_insert_contract_products()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L248"}, {"id": "crm_contract_product_delete_price_tiers_by_contract_id", "label": "delete_price_tiers_by_contract_id()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L292"}, {"id": "crm_contract_product_delete_products_by_contract_id", "label": "delete_products_by_contract_id()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L309"}, {"id": "crm_contract_product_save_price_tiers_for_product", "label": "save_price_tiers_for_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L325"}, {"id": "crm_contract_product_rationale_1", "label": "CRM \u5408\u540c\u4ea7\u54c1\u660e\u7ec6 CRUD \u2014 \u5bf9\u7167 CrmContractProductServiceImpl.java", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L1"}, {"id": "crm_contract_product_rationale_310", "label": "Java: deleteByContractId \u2014 \u8f6f\u5220\u9664\u4ea7\u54c1\u660e\u7ec6\u53ca\u8c03\u4ef7\u533a\u95f4", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L310"}, {"id": "crm_contract_product_rationale_335", "label": "Java: savePriceTiersForProduct", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L335"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_contract_product_py", "target": "json", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L4", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_contract_product_py", "target": "datetime", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract_product.py", "source_location": "L5", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_contract_product_py", "target": "decimal", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/wLine truncated
|
||||
backend/graphify-out/cache/ast/dc5215156270917090fb404c6f7455b48617e9554305fdc4c66fe70f0befe423.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_quote_py", "label": "quote.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L1"}, {"id": "crm_quote_ensure_quote_in_scope", "label": "_ensure_quote_in_scope()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L41"}, {"id": "crm_quote_get_quote_in_scope", "label": "_get_quote_in_scope()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L45"}, {"id": "crm_quote_total_pages", "label": "total_pages()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L59"}, {"id": "crm_quote_decimal_value", "label": "_decimal_value()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L65"}, {"id": "crm_quote_parse_optional_date", "label": "_parse_optional_date()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L73"}, {"id": "crm_quote_parse_optional_datetime", "label": "_parse_optional_datetime()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L91"}, {"id": "crm_quote_get_field", "label": "_get_field()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L107"}, {"id": "crm_quote_resolve_user_display_name", "label": "_resolve_user_display_name()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L114"}, {"id": "crm_quote_row_to_dict", "label": "_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L124"}, {"id": "crm_quote_quote_row_to_dict", "label": "quote_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L175"}, {"id": "crm_quote_build_quote_filters", "label": "_build_quote_filters()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L179"}, {"id": "crm_quote_fill_contract_linked_count", "label": "_fill_contract_linked_count()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L310"}, {"id": "crm_quote_calculate_quote_amount", "label": "calculate_quote_amount()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L332"}, {"id": "crm_quote_resolve_quote_product_ids", "label": "_resolve_quote_product_ids()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L358"}, {"id": "crm_quote_generate_quote_no", "label": "generate_quote_no()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L379"}, {"id": "crm_quote_generate_quote_title", "label": "generate_quote_title()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L429"}, {"id": "crm_quote_get_supplier_short_name", "label": "_get_supplier_short_name()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L441"}, {"id": "crm_quote_enrich_contact_fields", "label": "_enrich_contact_fields()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L454"}, {"id": "crm_quote_enrich_supplier_name", "label": "_enrich_supplier_name()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L465"}, {"id": "crm_quote_get_quote", "label": "get_quote()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L473"}, {"id": "crm_quote_get_quote_detail", "label": "get_quote_detail()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L485"}, {"id": "crm_quote_list_quotes", "label": "list_quotes()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L530"}, {"id": "crm_quote_create_quote", "label": "create_quote()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py", "source_location": "L561"}, {"id": "crm_quote_update_quote", "label": "update_quote()", "file_type": "cLine truncated
|
||||
backend/graphify-out/cache/ast/e8dd5c8ade993ab348146df8985ab96eebed5d5851369db9b8115d781fdf0ba4.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_product_py", "label": "product.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L1"}, {"id": "crm_product_total_pages", "label": "total_pages()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L24"}, {"id": "crm_product_category_row_to_dict", "label": "_category_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L34"}, {"id": "crm_product_list_categories", "label": "list_categories()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L50"}, {"id": "crm_product_build_category_tree", "label": "_build_category_tree()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L77"}, {"id": "crm_product_collect_category_with_ancestors", "label": "_collect_category_with_ancestors()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L88"}, {"id": "crm_product_list_category_tree", "label": "list_category_tree()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L104"}, {"id": "crm_product_get_category", "label": "get_category()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L137"}, {"id": "crm_product_create_category", "label": "create_category()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L149"}, {"id": "crm_product_update_category", "label": "update_category()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L186"}, {"id": "crm_product_soft_delete_category", "label": "soft_delete_category()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L225"}, {"id": "crm_product_category_code_exists", "label": "category_code_exists()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L265"}, {"id": "crm_product_product_row_to_dict", "label": "_product_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L285"}, {"id": "crm_product_list_products", "label": "list_products()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L316"}, {"id": "crm_product_get_product", "label": "get_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L381"}, {"id": "crm_product_get_product_by_no", "label": "get_product_by_no()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L393"}, {"id": "crm_product_max_sxwl_product_seq", "label": "_max_sxwl_product_seq()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L412"}, {"id": "crm_product_generate_product_no", "label": "generate_product_no()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L433"}, {"id": "crm_product_generate_barcode", "label": "generate_barcode()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L446"}, {"id": "crm_product_create_product", "label": "create_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L474"}, {"id": "crm_product_update_product", "label": "update_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L533"}, {"id": "crm_product_soft_delete_product", "label": "soft_delete_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L595"}, {"id": "crm_product_product_no_exists", "label": "product_no_exists()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L608"}, {"id": "crm_product_barcode_exists", "label": "barcode_exists()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py", "source_location": "L625"}, {"id": "crm_product_lisLine truncated
|
||||
backend/graphify-out/cache/ast/ea62dd2e46db811845636813309febe1efc26475f03cce76060ffe44849906d5.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_quote_product_py", "label": "quote_product.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L1"}, {"id": "crm_quote_product_ensure_quote_parent_scope", "label": "_ensure_quote_parent_scope()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L18"}, {"id": "crm_quote_product_decimal_value", "label": "_decimal_value()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L26"}, {"id": "crm_quote_product_get_field", "label": "_get_field()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L34"}, {"id": "crm_quote_product_calculate_subtotal", "label": "calculate_subtotal()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L41"}, {"id": "crm_quote_product_row_to_dict", "label": "_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L51"}, {"id": "crm_quote_product_product_row_to_dict", "label": "product_row_to_dict()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L79"}, {"id": "crm_quote_product_list_quote_products", "label": "list_quote_products()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L83"}, {"id": "crm_quote_product_get_quote_product", "label": "get_quote_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L105"}, {"id": "crm_quote_product_insert_quote_product", "label": "insert_quote_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L113"}, {"id": "crm_quote_product_update_quote_product", "label": "update_quote_product()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L182"}, {"id": "crm_quote_product_soft_delete_quote_products", "label": "soft_delete_quote_products()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L234"}, {"id": "crm_quote_product_batch_insert_quote_products", "label": "batch_insert_quote_products()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L268"}, {"id": "crm_quote_product_delete_products_by_quote_id", "label": "delete_products_by_quote_id()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L296"}, {"id": "crm_quote_product_rationale_1", "label": "CRM \u62a5\u4ef7\u5355\u4ea7\u54c1\u660e\u7ec6 CRUD \u2014 \u5bf9\u7167 CrmQuoteProductServiceImpl.java", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L1"}, {"id": "crm_quote_product_rationale_46", "label": "Java: calculateSubtotal", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L46"}, {"id": "crm_quote_product_rationale_52", "label": "ORM \u2192 camelCase\uff08\u5355\u70b9\u771f\u76f8\uff09", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L52"}, {"id": "crm_quote_product_rationale_90", "label": "Java: queryListByQuoteId", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L90"}, {"id": "crm_quote_product_rationale_241", "label": "Java: deleteWithValidByIds", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L241"}, {"id": "crm_quote_product_rationale_297", "label": "Java: deleteByQuoteId \u2014 \u8f6f\u5220\u9664\u8be5\u62a5\u4ef7\u5355\u4e0b\u5168\u90e8\u660e\u7ec6", "file_type": "rationale", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L297"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_crud_crm_quote_product_py", "target": "datetime", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote_product.py", "source_location": "L4", "weight": 1.0}Line truncated
|
||||
+9115
-7617
File diff suppressed because it is too large.
Load diff
+630
-298
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||
@@ -0,0 +1,21 @@
|
||||
from app.services.crm_supplier_hazard import product_allowed
|
||||
|
||||
|
||||
def test_plain_always_allowed():
|
||||
assert product_allowed(is_toxic=0, is_explosive=0, allow_toxic=0, allow_explosive=0) is True
|
||||
|
||||
|
||||
def test_toxic_only_requires_allow_toxic():
|
||||
assert product_allowed(1, 0, allow_toxic=1, allow_explosive=0) is True
|
||||
assert product_allowed(1, 0, allow_toxic=0, allow_explosive=1) is False
|
||||
|
||||
|
||||
def test_explosive_only_requires_allow_explosive():
|
||||
assert product_allowed(0, 1, allow_toxic=0, allow_explosive=1) is True
|
||||
assert product_allowed(0, 1, allow_toxic=1, allow_explosive=0) is False
|
||||
|
||||
|
||||
def test_dual_requires_both():
|
||||
assert product_allowed(1, 1, allow_toxic=1, allow_explosive=1) is True
|
||||
assert product_allowed(1, 1, allow_toxic=1, allow_explosive=0) is False
|
||||
assert product_allowed(1, 1, allow_toxic=0, allow_explosive=1) is False
|
||||
+2242
-382
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,519 @@
|
||||
# CRM 主体危化品能力配置与选品限制 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 配置安庆山峡/安徽云栋等主体能否做易制毒、易制爆产品,并在报价单、合同选品与保存时按主体能力限制产品。
|
||||
|
||||
**Architecture:** 在 `crm_supplier_info` 增加 `allow_toxic` / `allow_explosive`;共享纯函数判定选品合法性;产品 list 支持按 `supplierId` 过滤;报价/合同保存做后端校验;切换主体时前端自动剔除冲突明细并提示。合同管理下挂「主体危化品能力」配置页。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus;MySQL 5.7
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-26-crm-supplier-hazard-capability-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `backend/scripts/migrate_20260726_crm_supplier_hazard_capability.sql` | 增量 DDL + 菜单 seed |
|
||||
| `docs/sql/shanxia2.0.sql` | 同步 `crm_supplier_info` / `sys_menu` |
|
||||
| `backend/app/models/crm_models.py` | `CrmSupplierInfo` 两字段 |
|
||||
| `backend/app/schemas/crm/supplier_info.py` | Create/Update/响应字段 |
|
||||
| `backend/app/crud/crm/supplier_info.py` | `_row_to_dict` / create / update |
|
||||
| `backend/app/services/crm_supplier_hazard.py` | `product_allowed` + 校验明细 |
|
||||
| `backend/tests/test_crm_supplier_hazard.py` | 纯函数单测 |
|
||||
| `backend/app/schemas/crm/product.py` | `ProductListBody.supplierId` |
|
||||
| `backend/app/crud/crm/product.py` | list 按主体能力过滤 |
|
||||
| `backend/app/api/crm.py` | 透传 `supplierId` |
|
||||
| `backend/app/crud/crm/quote.py` | create/update 校验 |
|
||||
| `backend/app/crud/crm/contract.py` | create/update 校验 |
|
||||
| `web-admin/src/utils/crm-supplier-hazard.ts` | 前端同名判定 + 剔除 helper |
|
||||
| `web-admin/src/api/crm/supplierInfo/index.ts` | VO/Form 增加字段 |
|
||||
| `web-admin/src/api/crm/product.ts` | list 可传 `supplierId`(已通用) |
|
||||
| `web-admin/src/views/crm/supplier-hazard/index.vue` | 配置页 |
|
||||
| `web-admin/src/router/modules/sqxp.ts` | 路由 |
|
||||
| `web-admin/src/views/crm/quote/components/ProductSelector.vue` | 传 `supplierId` |
|
||||
| `web-admin/src/views/crm/contract/components/ProductSelector.vue` | 传 `supplierId` |
|
||||
| `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue` | 改主体剔除 + 打开选品前校验 |
|
||||
| `web-admin/src/views/crm/contract/components/ContractFormDialog.vue` | 同上 |
|
||||
| `web-admin/src/views/crm/contract/form.vue` | 同上(若仍在用) |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 共享判定函数 + 单测(后端)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/crm_supplier_hazard.py`
|
||||
- Create: `backend/tests/test_crm_supplier_hazard.py`
|
||||
|
||||
- [x] **Step 1: 写失败单测**
|
||||
|
||||
```python
|
||||
# backend/tests/test_crm_supplier_hazard.py
|
||||
from app.services.crm_supplier_hazard import product_allowed
|
||||
|
||||
|
||||
def test_plain_always_allowed():
|
||||
assert product_allowed(is_toxic=0, is_explosive=0, allow_toxic=0, allow_explosive=0) is True
|
||||
|
||||
|
||||
def test_toxic_only_requires_allow_toxic():
|
||||
assert product_allowed(1, 0, allow_toxic=1, allow_explosive=0) is True
|
||||
assert product_allowed(1, 0, allow_toxic=0, allow_explosive=1) is False
|
||||
|
||||
|
||||
def test_explosive_only_requires_allow_explosive():
|
||||
assert product_allowed(0, 1, allow_toxic=0, allow_explosive=1) is True
|
||||
assert product_allowed(0, 1, allow_toxic=1, allow_explosive=0) is False
|
||||
|
||||
|
||||
def test_dual_requires_both():
|
||||
assert product_allowed(1, 1, allow_toxic=1, allow_explosive=1) is True
|
||||
assert product_allowed(1, 1, allow_toxic=1, allow_explosive=0) is False
|
||||
assert product_allowed(1, 1, allow_toxic=0, allow_explosive=1) is False
|
||||
```
|
||||
|
||||
- [x] **Step 2: 跑测确认失败**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && pytest tests/test_crm_supplier_hazard.py -v`
|
||||
Expected: FAIL(模块不存在)
|
||||
|
||||
- [x] **Step 3: 实现**
|
||||
|
||||
```python
|
||||
# backend/app/services/crm_supplier_hazard.py
|
||||
"""主体危化品能力:产品是否允许选择/落库。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.crm.product import get_product # 若无 get_product,用 db.query(CrmProduct)
|
||||
from app.crud.crm.supplier_info import get_supplier_info
|
||||
|
||||
|
||||
def product_allowed(
|
||||
is_toxic: int,
|
||||
is_explosive: int,
|
||||
*,
|
||||
allow_toxic: int,
|
||||
allow_explosive: int,
|
||||
) -> bool:
|
||||
toxic = int(is_toxic or 0) == 1
|
||||
explosive = int(is_explosive or 0) == 1
|
||||
if not toxic and not explosive:
|
||||
return True
|
||||
if toxic and explosive:
|
||||
return int(allow_toxic or 0) == 1 and int(allow_explosive or 0) == 1
|
||||
if toxic:
|
||||
return int(allow_toxic or 0) == 1
|
||||
return int(allow_explosive or 0) == 1
|
||||
|
||||
|
||||
def get_supplier_hazard_flags(
|
||||
db: Session,
|
||||
supplier_id: Optional[str],
|
||||
*,
|
||||
tenant_no: str,
|
||||
) -> Tuple[int, int]:
|
||||
"""返回 (allow_toxic, allow_explosive);无主体时视为 (0, 0)。"""
|
||||
if not supplier_id:
|
||||
return 0, 0
|
||||
row = get_supplier_info(db, str(supplier_id), tenant_no=tenant_no)
|
||||
if not row:
|
||||
return 0, 0
|
||||
return int(getattr(row, "allow_toxic", 0) or 0), int(getattr(row, "allow_explosive", 0) or 0)
|
||||
|
||||
|
||||
def assert_products_allowed_for_supplier(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
supplier_id: Optional[str],
|
||||
products: Iterable[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""不合规则 raise ValueError,文案列出产品名。"""
|
||||
allow_toxic, allow_explosive = get_supplier_hazard_flags(db, supplier_id, tenant_no=tenant_no)
|
||||
blocked: List[str] = []
|
||||
for p in products or []:
|
||||
pid = str(p.get("productId") or p.get("product_id") or "").strip()
|
||||
if not pid:
|
||||
continue
|
||||
prod = get_product(db, pid, tenant_no=tenant_no) # 按现有 CRUD 签名调整
|
||||
if not prod:
|
||||
continue
|
||||
is_toxic = int(getattr(prod, "is_toxic", 0) or p.get("isToxic") or 0)
|
||||
is_explosive = int(getattr(prod, "is_explosive", 0) or p.get("isExplosive") or 0)
|
||||
if not product_allowed(is_toxic, is_explosive, allow_toxic=allow_toxic, allow_explosive=allow_explosive):
|
||||
name = getattr(prod, "product_name", None) or p.get("productName") or pid
|
||||
blocked.append(str(name))
|
||||
if blocked:
|
||||
raise ValueError(
|
||||
"当前主体不具备以下产品的危化品经营能力,请移除后重试:" + "、".join(blocked)
|
||||
)
|
||||
```
|
||||
|
||||
> 实现时核对 `get_product` / `get_supplier_info` 真实签名;若无 `get_product`,用 `db.query(CrmProduct).filter(...).first()`。
|
||||
|
||||
- [x] **Step 4: 跑测通过**
|
||||
|
||||
Run: `pytest tests/test_crm_supplier_hazard.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**(仅在用户要求提交时执行)
|
||||
|
||||
```bash
|
||||
git add backend/app/services/crm_supplier_hazard.py backend/tests/test_crm_supplier_hazard.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(crm): add supplier hazard product_allowed helper and unit tests
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: DB 字段 + Model + Supplier CRUD/Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/scripts/migrate_20260726_crm_supplier_hazard_capability.sql`(本 Task 先写 ALTER,菜单放 Task 5)
|
||||
- Modify: `docs/sql/shanxia2.0.sql`(`crm_supplier_info` CREATE)
|
||||
- Modify: `backend/app/models/crm_models.py`
|
||||
- Modify: `backend/app/schemas/crm/supplier_info.py`
|
||||
- Modify: `backend/app/crud/crm/supplier_info.py`
|
||||
|
||||
- [x] **Step 1: 迁移 SQL(字段部分)**
|
||||
|
||||
```sql
|
||||
-- backend/scripts/migrate_20260726_crm_supplier_hazard_capability.sql
|
||||
ALTER TABLE `crm_supplier_info`
|
||||
ADD COLUMN `allow_toxic` smallint(6) NOT NULL DEFAULT 0 COMMENT '是否允许易制毒产品(0否1是)' AFTER `bank_account`,
|
||||
ADD COLUMN `allow_explosive` smallint(6) NOT NULL DEFAULT 0 COMMENT '是否允许易制爆产品(0否1是)' AFTER `allow_toxic`;
|
||||
```
|
||||
|
||||
在本地/测试库执行该 ALTER。同步修改 `docs/sql/shanxia2.0.sql` 中 `CREATE TABLE crm_supplier_info`,在 `bank_account` 后加入同两列。
|
||||
|
||||
- [x] **Step 2: Model**
|
||||
|
||||
在 `CrmSupplierInfo`(`status` 之前)增加:
|
||||
|
||||
```python
|
||||
allow_toxic = Column(SmallInteger, nullable=False, default=0, comment="是否允许易制毒产品(0否1是)")
|
||||
allow_explosive = Column(SmallInteger, nullable=False, default=0, comment="是否允许易制爆产品(0否1是)")
|
||||
```
|
||||
|
||||
- [x] **Step 3: Schema**
|
||||
|
||||
`SupplierInfoCreateBody` / `SupplierInfoUpdateBody` 增加:
|
||||
|
||||
```python
|
||||
allow_toxic: Optional[int] = Field(default=0, alias="allowToxic", description="是否允许易制毒(0/1)")
|
||||
allow_explosive: Optional[int] = Field(default=0, alias="allowExplosive", description="是否允许易制爆(0/1)")
|
||||
```
|
||||
|
||||
UpdateBody 默认用 `None` 表示不改:
|
||||
|
||||
```python
|
||||
allow_toxic: Optional[int] = Field(default=None, alias="allowToxic")
|
||||
allow_explosive: Optional[int] = Field(default=None, alias="allowExplosive")
|
||||
```
|
||||
|
||||
- [x] **Step 4: CRUD `_row_to_dict` + create/update**
|
||||
|
||||
`_row_to_dict` 增加:
|
||||
|
||||
```python
|
||||
"allowToxic": int(row.allow_toxic or 0),
|
||||
"allowExplosive": int(row.allow_explosive or 0),
|
||||
```
|
||||
|
||||
`create_supplier_info` / `update_supplier_info` 写入 `allow_toxic` / `allow_explosive`(update 仅当 kwargs 含该键时设置)。
|
||||
|
||||
- [x] **Step 5: 手动冒烟**
|
||||
|
||||
调用 `listAll` 供方接口,确认返回含 `allowToxic`/`allowExplosive`(默认 0)。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 产品 list 按主体过滤
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/crm/product.py` — `ProductListBody`
|
||||
- Modify: `backend/app/crud/crm/product.py` — `list_products`
|
||||
- Modify: `backend/app/api/crm.py` — products list 透传
|
||||
|
||||
- [x] **Step 1: Schema**
|
||||
|
||||
```python
|
||||
supplier_id: Optional[str] = Field(default=None, alias="supplierId", description="报价/合同主体ID,传入则按危化品能力过滤")
|
||||
```
|
||||
|
||||
- [x] **Step 2: list_products**
|
||||
|
||||
增加参数 `supplier_id: str = None`。在 status 过滤之后、count 之前:
|
||||
|
||||
```python
|
||||
if supplier_id:
|
||||
from app.services.crm_supplier_hazard import get_supplier_hazard_flags, product_allowed
|
||||
allow_toxic, allow_explosive = get_supplier_hazard_flags(db, supplier_id, tenant_no=tenant_no)
|
||||
# 内存过滤:先查出候选再 filter;若数据量大可改为 SQL OR 条件
|
||||
```
|
||||
|
||||
推荐 SQL 条件(与 `product_allowed` 等价):
|
||||
|
||||
```python
|
||||
# 普通: (is_toxic!=1 AND is_explosive!=1)
|
||||
# 或 仅毒且 allow_toxic
|
||||
# 或 仅爆且 allow_explosive
|
||||
# 或 双属性且两项都开
|
||||
from sqlalchemy import and_, or_, not_
|
||||
|
||||
conds = [
|
||||
and_(
|
||||
or_(CrmProduct.is_toxic.is_(None), CrmProduct.is_toxic != 1),
|
||||
or_(CrmProduct.is_explosive.is_(None), CrmProduct.is_explosive != 1),
|
||||
)
|
||||
]
|
||||
if allow_toxic == 1:
|
||||
conds.append(and_(CrmProduct.is_toxic == 1, or_(CrmProduct.is_explosive.is_(None), CrmProduct.is_explosive != 1)))
|
||||
if allow_explosive == 1:
|
||||
conds.append(and_(CrmProduct.is_explosive == 1, or_(CrmProduct.is_toxic.is_(None), CrmProduct.is_toxic != 1)))
|
||||
if allow_toxic == 1 and allow_explosive == 1:
|
||||
conds.append(and_(CrmProduct.is_toxic == 1, CrmProduct.is_explosive == 1))
|
||||
q = q.filter(or_(*conds))
|
||||
```
|
||||
|
||||
- [x] **Step 3: API 透传** `body.supplier_id` → `list_products(..., supplier_id=...)`
|
||||
|
||||
- [x] **Step 4: 冒烟**
|
||||
|
||||
- 不传 `supplierId`:产品管理列表条数不变
|
||||
- 传仅开毒的主体:不含「仅爆」「双属性」产品
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 报价 / 合同保存校验
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/crm/quote.py` — `create_quote` / `update_quote`
|
||||
- Modify: `backend/app/crud/crm/contract.py` — create/update 写产品处
|
||||
|
||||
- [x] **Step 1: quote**
|
||||
|
||||
在 `create_quote` / `update_quote` 解析出 `supplier_id` 与 `product_rows` 后、落库前:
|
||||
|
||||
```python
|
||||
from app.services.crm_supplier_hazard import assert_products_allowed_for_supplier
|
||||
|
||||
assert_products_allowed_for_supplier(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
supplier_id=str(supplier_id) if supplier_id else None,
|
||||
products=product_rows or [],
|
||||
)
|
||||
```
|
||||
|
||||
`update_quote` 仅在本次请求带了 `products`/`productList` 时校验;若只改头不改明细,应用库中现有明细 + 新 `supplier_id` 再校验(防只改主体留下冲突品)。
|
||||
|
||||
- [x] **Step 2: contract**
|
||||
|
||||
在合同 create/update 写入 `crm_contract_product` 前调用同一 `assert_products_allowed_for_supplier`,`supplier_id` 取合同交易主体。
|
||||
|
||||
- [x] **Step 3: 冒烟**
|
||||
|
||||
用 API 保存含冲突产品的报价 → 期望业务错误文案含产品名。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 菜单 + 配置页 + 路由
|
||||
|
||||
**Files:**
|
||||
- Append: `backend/scripts/migrate_20260726_crm_supplier_hazard_capability.sql`(菜单)
|
||||
- Modify: `docs/sql/shanxia2.0.sql`(sys_menu INSERT)
|
||||
- Create: `web-admin/src/views/crm/supplier-hazard/index.vue`
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||||
- Modify: `web-admin/src/api/crm/supplierInfo/index.ts`
|
||||
|
||||
- [x] **Step 1: 菜单 SQL**
|
||||
|
||||
合同 `order_num=6`,客户 `order_num=7`。将 `order_num >= 7` 的 CRM 子菜单 +1,再插入本菜单 `order_num=7`:
|
||||
|
||||
```sql
|
||||
UPDATE `sys_menu`
|
||||
SET `order_num` = `order_num` + 1
|
||||
WHERE `parent_id` = 'CRM00000000000000000000000000001'
|
||||
AND `order_num` >= 7
|
||||
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`
|
||||
) VALUES (
|
||||
'CRM3000000000000000000000000025',
|
||||
'主体危化品能力',
|
||||
'CRM00000000000000000000000000001',
|
||||
'C',
|
||||
'supplier-hazard',
|
||||
'/crm/supplier-hazard/index',
|
||||
'crm:supplierHazard:list',
|
||||
1, '1', '1', 'shanxia2.0', 7,
|
||||
'配置主体是否允许易制毒/易制爆产品',
|
||||
'system', '', NOW(), NULL, NOW(), 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 (
|
||||
'CRM3000000000000000000000000026',
|
||||
'主体危化品能力编辑',
|
||||
'CRM3000000000000000000000000025',
|
||||
'F',
|
||||
'', NULL,
|
||||
'crm:supplierHazard:edit',
|
||||
1, '0', '1', 'shanxia2.0', 1, NULL,
|
||||
'system', '', NOW(), NULL, NOW(), 0
|
||||
);
|
||||
```
|
||||
|
||||
给相关角色(至少超管)分配新菜单(按项目现有 `sys_role_menu` 习惯补 INSERT,或登录后在角色管理勾选)。
|
||||
|
||||
- [x] **Step 2: 前端 API 类型**
|
||||
|
||||
`SupplierInfoVO` / `SupplierInfoForm` 增加 `allowToxic?: number`、`allowExplosive?: number`。
|
||||
|
||||
- [x] **Step 3: 路由**
|
||||
|
||||
在 `sqxp.ts` 合同路由附近增加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'CRM3000000000000000000000000025',
|
||||
name: 'CrmSupplierHazard',
|
||||
path: 'supplier-hazard',
|
||||
component: '/crm/supplier-hazard/index',
|
||||
meta: { title: '主体危化品能力', keepAlive: true, menuCode: 'crm:supplierHazard:list' }
|
||||
},
|
||||
```
|
||||
|
||||
- [x] **Step 4: 配置页**
|
||||
|
||||
新建 `web-admin/src/views/crm/supplier-hazard/index.vue`:
|
||||
- 结构对齐 CRM 列表:`panel-top` 标题「主体危化品能力」+ `listAllSupplierInfo` / 分页 list
|
||||
- 列:主体名称、编码、允许易制毒(`el-switch`)、允许易制爆(`el-switch`)、状态 Tag、更新时间
|
||||
- switch `@change` 调 `updateSupplierInfo({ id, allowToxic, allowExplosive, supplierName })`(update 若要求名称,带上当前行名称)
|
||||
- 按钮带图标;状态/类型用 `el-tag`
|
||||
- `App.vue` `panelTitleBarTextRules` 如需竖条则登记「主体危化品能力」
|
||||
|
||||
- [x] **Step 5: 浏览器**
|
||||
|
||||
打开 `/#/crm/supplier-hazard`,切换开关后刷新仍保持。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端选品过滤 + 改主体剔除
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/utils/crm-supplier-hazard.ts`
|
||||
- Modify: quote/contract `ProductSelector.vue`
|
||||
- Modify: `QuoteFormPanel.vue`
|
||||
- Modify: `ContractFormDialog.vue`(及 `form.vue` 若仍入口)
|
||||
|
||||
- [x] **Step 1: 工具函数**
|
||||
|
||||
```typescript
|
||||
// web-admin/src/utils/crm-supplier-hazard.ts
|
||||
export function productAllowedByHazard(
|
||||
isToxic: number | undefined,
|
||||
isExplosive: number | undefined,
|
||||
allowToxic: number,
|
||||
allowExplosive: number
|
||||
): boolean {
|
||||
const toxic = Number(isToxic || 0) === 1
|
||||
const explosive = Number(isExplosive || 0) === 1
|
||||
if (!toxic && !explosive) return true
|
||||
if (toxic && explosive) return allowToxic === 1 && allowExplosive === 1
|
||||
if (toxic) return allowToxic === 1
|
||||
return allowExplosive === 1
|
||||
}
|
||||
|
||||
export function removeProductsNotAllowedByHazard<T extends { productId?: string | number; productName?: string; isToxic?: number; isExplosive?: number }>(
|
||||
products: T[],
|
||||
allowToxic: number,
|
||||
allowExplosive: number
|
||||
): { kept: T[]; removedNames: string[] } {
|
||||
const kept: T[] = []
|
||||
const removedNames: string[] = []
|
||||
for (const p of products) {
|
||||
if (productAllowedByHazard(p.isToxic, p.isExplosive, allowToxic, allowExplosive)) {
|
||||
kept.push(p)
|
||||
} else {
|
||||
removedNames.push(p.productName || String(p.productId || ''))
|
||||
}
|
||||
}
|
||||
return { kept, removedNames }
|
||||
}
|
||||
```
|
||||
|
||||
注意:明细行可能没有 `isToxic`/`isExplosive`。改主体时若缺失,应先用当前 `supplierList` 能力 + 必要时批量查产品详情,或在加入明细时把 hazard 快照进行。**实现约定:** `handleProductConfirm` 写入行时带上 `isToxic`/`isExplosive`(从选中产品对象拷贝);改主体剔除依赖行上快照。
|
||||
|
||||
- [x] **Step 2: ProductSelector**
|
||||
|
||||
增加 prop `supplierId?: string | number`。`fetchProductList` 请求体带 `supplierId`。无 `supplierId` 时父组件不应打开弹窗(或弹窗内提示先选主体)。
|
||||
|
||||
- [x] **Step 3: QuoteFormPanel**
|
||||
|
||||
- 打开选品前:无 `form.supplierId` → `ElMessage.warning('请先选择报价主体')`
|
||||
- `handleSupplierChange`:取新主体 `allowToxic`/`allowExplosive`,`removeProductsNotAllowedByHazard`,写回 `form.products`,若有 `removedNames` → `ElMessage.warning(\`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}\`)`
|
||||
- `<ProductSelector :supplier-id="form.supplierId" ... />`
|
||||
|
||||
- [x] **Step 4: ContractFormDialog / form.vue**
|
||||
|
||||
同样处理交易主体变更与 `ProductSelector`。
|
||||
|
||||
- [x] **Step 5: 手工验收**
|
||||
|
||||
按 spec「测试要点」1–6 走一遍。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 收尾
|
||||
|
||||
- [x] 跑 `pytest tests/test_crm_supplier_hazard.py -v`
|
||||
- [x] 若改了字段一致性相关实体,按需跑 `python scripts/check_field_consistency.py`(供方)
|
||||
- [x] `./scripts/update-graph.sh backend` 与 frontend(若脚本可用)
|
||||
- [x] 更新 design spec 状态为「已实现」日期(实现完成后)
|
||||
|
||||
---
|
||||
|
||||
## Spec 覆盖自检
|
||||
|
||||
| Spec 项 | Task |
|
||||
|---------|------|
|
||||
| `allow_toxic` / `allow_explosive` 字段 | Task 2 |
|
||||
| 双属性须两项都开 | Task 1 |
|
||||
| 配置菜单「主体危化品能力」 | Task 5 |
|
||||
| 报价/合同选品过滤 | Task 3 + 6 |
|
||||
| 改主体自动剔除并提示 | Task 6 |
|
||||
| 保存后端校验 | Task 4 |
|
||||
| 存量不回溯 | 无额外任务(只读历史天然满足) |
|
||||
| 产品管理不受过滤 | Task 3(不传 supplierId) |
|
||||
|
||||
---
|
||||
|
||||
## 执行说明
|
||||
|
||||
Plan 已保存。实现前请先在目标库执行 Task 2/5 的 SQL。
|
||||
|
||||
**执行方式二选一:**
|
||||
|
||||
1. **Subagent-Driven(推荐)** — 每 Task 开新子代理,Task 间复查
|
||||
2. **Inline Execution** — 本会话按 executing-plans 连续做完
|
||||
|
||||
选哪种?
|
||||
@@ -0,0 +1,154 @@
|
||||
# CRM 主体危化品能力配置与报价/合同选品限制
|
||||
|
||||
**日期:** 2026-07-26
|
||||
**状态:** 已实现(待执行库迁移后验收)
|
||||
**范围:** web-admin CRM(供方能力配置 + 报价单/合同选品)+ backend 字段与校验
|
||||
|
||||
## 背景
|
||||
|
||||
- 报价「报价主体 / 采购主体」、合同「交易主体」均来自 `crm_supplier_info`(如安庆山峡、安徽云栋)。
|
||||
- 产品危化属性在 `crm_product.is_toxic` / `is_explosive`。
|
||||
- 当前报价、合同选品**不按主体能力过滤**;主体表也无「能否做易制毒/易制爆」配置。
|
||||
- 下游运输计划已按产品属性要求证书;CRM 源头若不管控,不合规产品会进入合同链路。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 在「合同管理」下方增加配置菜单,维护各主体是否允许易制毒、易制爆产品。
|
||||
2. 报价单、合同选品按当前主体能力过滤可选产品。
|
||||
3. 切换主体时自动剔除冲突产品并提示;保存时后端再次校验防绕过。
|
||||
|
||||
## 非目标(YAGNI)
|
||||
|
||||
- 不新建独立能力表(首版扩展 `crm_supplier_info`)。
|
||||
- 不做能力变更审批流、不做按品类细分许可。
|
||||
- 不回溯改写历史已保存报价/合同明细。
|
||||
- 不改造运输计划证书逻辑(与本需求互补、独立)。
|
||||
- 不做完整「供方信息」CRUD 菜单(配置页只改两开关相关字段)。
|
||||
|
||||
## 已确认业务规则
|
||||
|
||||
### 选品矩阵
|
||||
|
||||
| 产品属性 | 主体需具备 |
|
||||
|---------|-----------|
|
||||
| 非毒且非爆(普通) | 始终可选 |
|
||||
| 仅易制毒 | `allow_toxic = 1` |
|
||||
| 仅易制爆 | `allow_explosive = 1` |
|
||||
| 易制毒且易制爆 | **两项均为 1** |
|
||||
| 主体两项都为 0 | 仅可选普通品 |
|
||||
|
||||
判定伪代码:
|
||||
|
||||
```
|
||||
function productAllowed(product, allowToxic, allowExplosive):
|
||||
toxic = product.is_toxic == 1
|
||||
explosive = product.is_explosive == 1
|
||||
if not toxic and not explosive: return true
|
||||
if toxic and explosive: return allowToxic and allowExplosive
|
||||
if toxic: return allowToxic
|
||||
if explosive: return allowExplosive
|
||||
return false
|
||||
```
|
||||
|
||||
### 切换主体
|
||||
|
||||
- **允许**修改报价主体 / 交易主体。
|
||||
- 切换后扫描当前明细:不满足新主体能力的行**自动剔除**。
|
||||
- 用 Message 提示已移除的产品名称列表(无冲突则不提示)。
|
||||
|
||||
### 存量单据
|
||||
|
||||
- 已保存单据只读展示不回溯。
|
||||
- 再次编辑时:选品弹窗按新配置过滤;若打开时主体已变或能力已变,保存前按当前能力校验;明细中若已有冲突品,编辑态切换主体走「自动剔除」;若未改主体仅改能力配置,打开编辑保存时后端拒绝冲突明细并提示。
|
||||
|
||||
## 数据设计
|
||||
|
||||
### `crm_supplier_info` 新增字段
|
||||
|
||||
| 列 | 类型 | 默认 | 说明 |
|
||||
|----|------|------|------|
|
||||
| `allow_toxic` | `smallint` NOT NULL | `0` | 是否允许易制毒产品(0否 1是) |
|
||||
| `allow_explosive` | `smallint` NOT NULL | `0` | 是否允许易制爆产品(0否 1是) |
|
||||
|
||||
- 放在业务字段区(`status` 之前或之后均可),公共字段顺序不变。
|
||||
- API / 前端别名:`allowToxic` / `allowExplosive`。
|
||||
- 迁移脚本:`backend/scripts/` + 同步 `docs/sql/shanxia2.0.sql`。
|
||||
- 既有主体默认均为 0(仅普通品),由配置页显式打开。
|
||||
|
||||
### 菜单与权限
|
||||
|
||||
- 父级:CRM「合同管理」同级或紧挨其下(侧栏顺序在合同管理之后)。
|
||||
- 菜单名:**主体危化品能力**。
|
||||
- 路由建议:`/#/crm/supplier-hazard` → `web-admin/src/views/crm/supplier-hazard/index.vue`。
|
||||
- 权限码:
|
||||
- `crm:supplierHazard:list` 查看
|
||||
- `crm:supplierHazard:edit` 保存开关
|
||||
- `sys_menu` seed / 迁移 SQL 写入;前端路由按现有 CRM 动态菜单或 `sqxp.ts` 习惯注册。
|
||||
|
||||
## 界面设计
|
||||
|
||||
### 配置页(P01 标准列表风格)
|
||||
|
||||
- 对齐 `system/unit` / 现有 CRM 列表:筛选可选「主体名称」,主表列:主体名称、编码、是否允许易制毒、是否允许易制爆、状态、更新时间。
|
||||
- 「是否允许」用 `el-switch` 或可编辑 Tag;工具栏「保存」批量提交变更,或行内即时保存(实现时二选一,推荐**行内 switch 即时保存**以减少步骤)。
|
||||
- 仅展示 `del_flag=0` 且建议默认 `status=0`(正常)的主体;停用主体仍可查看但不作为报价/合同下拉(与现网一致)。
|
||||
|
||||
### 报价 / 合同选品
|
||||
|
||||
- `ProductSelector`(报价与合同各有一份,或抽公共过滤)调用产品列表时传入当前 `supplierId`,或前端拿到主体能力后本地过滤(**推荐后端 list 支持按主体过滤**,避免漏网)。
|
||||
- 未选主体时:禁止打开选品或提示「请先选择报价主体/交易主体」。
|
||||
- 产品列表可展示易制毒/易制爆 Tag,便于用户理解过滤结果(可选增强,非必须)。
|
||||
|
||||
### 切换主体 UX
|
||||
|
||||
1. 用户变更 `supplierId`。
|
||||
2. 拉取新主体 `allowToxic` / `allowExplosive`(若列表已带则不用再请求)。
|
||||
3. 过滤 `form.products`,收集被删名称。
|
||||
4. 若有删除:`ElMessage.warning('当前主体不支持以下产品,已自动移除:xxx、yyy')`。
|
||||
|
||||
## API / 后端
|
||||
|
||||
### 供方
|
||||
|
||||
- `list` / `listAll` / `detail` / `update` 读写 `allowToxic` / `allowExplosive`。
|
||||
- 配置页可用现有 `updateSupplierInfo`,或增加专用 `PATCH` 只更新两开关(非必须)。
|
||||
|
||||
### 产品列表(推荐)
|
||||
|
||||
- `ProductListBody` 增加可选 `supplierId`。
|
||||
- 若传入:加载该主体能力,SQL/内存过滤不符合的产品。
|
||||
- 不传则保持现状(产品管理页不受影响)。
|
||||
|
||||
### 报价 / 合同保存校验(必须)
|
||||
|
||||
- create/update 时:根据 `supplier_id` 读能力,对每个 `product_id` 查 `crm_product` 危化标志,调用同一 `productAllowed`;任一不符合 → `400`,文案列出产品名。
|
||||
- 报价转合同、从报价带入明细时同样校验(若有该入口)。
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. 山峡仅开毒:可选仅毒 + 普通;不可选仅爆、双属性。
|
||||
2. 山峡仅开爆:对称。
|
||||
3. 两项都开:毒、爆、双属性、普通均可。
|
||||
4. 两项都关:仅普通。
|
||||
5. 报价改主体:冲突品消失并提示名称。
|
||||
6. 合同同上。
|
||||
7. 绕过前端直接 API 保存冲突品 → 被拒。
|
||||
8. 产品管理列表不受 `supplierId` 过滤影响。
|
||||
|
||||
## 实现顺序建议
|
||||
|
||||
1. DB 字段 + model/schema/CRUD + SQL 文档
|
||||
2. 配置页 + 菜单权限
|
||||
3. 共享 `productAllowed`(前后端语义一致;后端权威)
|
||||
4. 产品 list 按主体过滤 + 报价/合同 ProductSelector
|
||||
5. 切换主体自动剔除
|
||||
6. 报价/合同保存校验
|
||||
|
||||
## 开放项(实现前默认)
|
||||
|
||||
| 项 | 默认决定 |
|
||||
|----|---------|
|
||||
| 配置保存方式 | 行内 switch 即时保存 |
|
||||
| 选品过滤位置 | 后端 list 支持 `supplierId` + 前端传参 |
|
||||
| 采购报价「采购主体」 | 与销售「报价主体」同一套能力字段 |
|
||||
| 菜单文案 | 「主体危化品能力」 |
|
||||
@@ -0,0 +1,17 @@
|
||||
# 运输计划打印单据按钮收拢为下拉
|
||||
|
||||
## 背景
|
||||
|
||||
运输计划列表工具栏、查看弹窗操作区、运输安排成功底栏各有三个并列打印按钮(入库单 / 送(提)货单 / 分装单),占用横向空间。客户希望收归为一个「打印单据」入口,再下拉选择具体单据。
|
||||
|
||||
## 决策
|
||||
|
||||
- 三处入口统一为中性描边按钮「打印单据」(`Printer` + 文案 + `ArrowDown`)。
|
||||
- 点击展开菜单,顺序固定:入库单 → 送(提)货单 → 分装单。
|
||||
- 选中后仍调用现有三个打印弹窗的 `open(planId)`,不改模板与后端。
|
||||
- 列表工具栏仍要求选中恰好 1 行;不按计划类型隐藏菜单项。
|
||||
|
||||
## 改动文件
|
||||
|
||||
- `web-admin/src/views/pl/plan/index.vue`(工具栏 + 查看弹窗)
|
||||
- `web-admin/src/components/business/pl-plan-arrange-dialog/index.vue`(安排成功底栏)
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"/Users/wuzilong/work/00code/shanxia2.0/.agents/skills/brainstorming/scripts/start-server.sh":{"size":4606,"mtime_ns":1780108930000000000,"hash":"7c3c6c5cce454f755159758334ffb434c970a29960bbff6669efe0cb22d1f2fb"},"/Users/wuzilong/work/00code/shanxia2.0/.agents/skills/brainstorming/scripts/stop-server.sh":{"size":1314,"mtime_ns":1780108930000000000,"hash":"9581ff879388aaa10463aea1fc1e9f9c00d69f2106f055ac976f97513ab84aba"},"/Users/wuzilong/work/00code/shanxia2.0/.agents/skills/systematic-debugging/find-polluter.sh":{"size":1528,"mtime_ns":1780108930000000000,"hash":"8340aac47e1279874e46d7ecb4954b5faae531a883abf05b118951687d8ca044"},"/Users/wuzilong/work/00code/shanxia2.0/.cursor/mcp.json":{"size":468,"mtime_ns":1780120483000000000,"hash":"ce4b438767a095c6a830e4612c221303ea16ab46af23f438bfb033eace904c36"},"/Users/wuzilong/work/00code/shanxia2.0/.opencode/opencode.json":{"size":105,"mtime_ns":1780120095000000000,"hash":"936122822138b12f363fb9b7a8567edaa1f834c835137a4c72d81c17116ce11e"},"/Users/wuzilong/work/00code/shanxia2.0/.opencode/package.json":{"size":65,"mtime_ns":1780109751000000000,"hash":"cf97d417f1ebce0ba81ad4629a5345e13cc95cb799c6eb2632caead5364880de"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_17f66cf13ffeo6JpSbBDM7Jx4t.json":{"size":214,"mtime_ns":1780274139000000000,"hash":"26182a523926188400188b8cc668585f8e81586416fa2399fd3585b0c07ff86c"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_1818ffbf9ffezXCeRFVV3Jj01O.json":{"size":263,"mtime_ns":1780238536000000000,"hash":"bf2e860eaa6fa5051d4cb0f70a53cf419b558c57759154e559712efef636dcb5"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_181f43997ffeO8mkoWfZl8v2Ri.json":{"size":214,"mtime_ns":1780236423000000000,"hash":"8252a79ead22a73fe0e229ef4eb6e1aac159547cdc04607d4102c75b077501a2"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_1823f3f66ffesCD5qohVVcWtbf.json":{"size":214,"mtime_ns":1780230242000000000,"hash":"27ba5fb0606f8ddda44c4f01ce5fbce77d7429f890317c6a1a6f9456058b1f70"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_183d2d323ffeBMoLOLwwPmAXym.json":{"size":214,"mtime_ns":1780210339000000000,"hash":"26814a22a2f0ee80b65a7306c4f52becca5edb09358cc12b588efa896546f571"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_183f72fc4ffeCZI7jMs3P9gB5n.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"cd6c278503c190ec5dae59675957e9a963cbd94ce09cf974b838a0e717b42817"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_184f894aeffej3cvErXgdYFhY8.json":{"size":263,"mtime_ns":1780201410000000000,"hash":"d3bb6c88a4c332c09d2684c3a34f10e311620956a8777a894cb1492ab15cce86"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_186a13679ffenULQIK102x2NfS.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"eb2fe454abea5ef36854bc2858ba6e9ac5e8f4d18e0121901f007816e0e40ebe"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_187ac729affeBH1jDFb5X7rWz0.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"b571d2be1c871201f1d572c536b57c89a277b0b3eb3f3a3e4125db5008b37a3f"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_18880f06fffeJSijqM7oWZ6JYI.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"efb86e46a8d0c2af314450407bfacd3f422ec8f68025291a440760cffe4ca092"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_1892f6be2ffe1K80MWGl0oc2Fo.json":{"size":214,"mtime_ns":1780114611000000000,"hash":"9b109fac4a24678ea27b4afe2840bcd06c339b490508d6aa531e051fe3f034b7"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/__init__.py":{"size":21,"mtime_ns":1780113982000000000,"hash":"5d7c80dd077475acb7eb12227dc0e7cf38bbb5fd9e84c4eab9eb2be8c5406357"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/__init__.py":{"size":665,"mtime_ns":1780804649000000000,"hash":"349c40e55e8aae86d0913a17da2f9d338d842243e5fdf807bad52382bc2876d9"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/admin.py":{"size":50055,"mtime_ns":1782855138000000000,"hash":"dafdefef3582e9e2bb3a16d9fe248cd2bf15cfa5db19d84e5b6025810f365088"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/__init__.py":{"size":777,"mtime_ns":1780751360000000000,"hash":"e3c6b2987e7234b74681069e68a75f5bca98d14145e23a4c3881c8d1fbbd3dc7"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/advance.py":{"size":13370,"mtime_ns":1783745567000000000,"hash":"72e7291132b46de536132d73e6e25e4e159c3791589a67fb0bd63c4a2de33eda"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/attachment.py":{"size":5758,"mtime_ns":1782790898000000000,"hash":"f2d20fc5b2a98748835ae1710666f11eae1aa323d80a4678189bb2d032559b4b"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/expense_item.py":{"size":3362,"mtime_ns":1782650913000000000,"hash":"142f0f6a3e45cb5d7b8153ee3048c5fb82258167264f325dd4fa8801b2e56c35"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/apLine truncated
|
||||
{"/Users/wuzilong/work/00code/shanxia2.0/.agents/skills/brainstorming/scripts/start-server.sh":{"size":4606,"mtime_ns":1780108930000000000,"hash":"7c3c6c5cce454f755159758334ffb434c970a29960bbff6669efe0cb22d1f2fb"},"/Users/wuzilong/work/00code/shanxia2.0/.agents/skills/brainstorming/scripts/stop-server.sh":{"size":1314,"mtime_ns":1780108930000000000,"hash":"9581ff879388aaa10463aea1fc1e9f9c00d69f2106f055ac976f97513ab84aba"},"/Users/wuzilong/work/00code/shanxia2.0/.agents/skills/systematic-debugging/find-polluter.sh":{"size":1528,"mtime_ns":1780108930000000000,"hash":"8340aac47e1279874e46d7ecb4954b5faae531a883abf05b118951687d8ca044"},"/Users/wuzilong/work/00code/shanxia2.0/.cursor/mcp.json":{"size":468,"mtime_ns":1780120483000000000,"hash":"ce4b438767a095c6a830e4612c221303ea16ab46af23f438bfb033eace904c36"},"/Users/wuzilong/work/00code/shanxia2.0/.opencode/opencode.json":{"size":105,"mtime_ns":1780120095000000000,"hash":"936122822138b12f363fb9b7a8567edaa1f834c835137a4c72d81c17116ce11e"},"/Users/wuzilong/work/00code/shanxia2.0/.opencode/package.json":{"size":65,"mtime_ns":1780109751000000000,"hash":"cf97d417f1ebce0ba81ad4629a5345e13cc95cb799c6eb2632caead5364880de"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_17f66cf13ffeo6JpSbBDM7Jx4t.json":{"size":214,"mtime_ns":1780274139000000000,"hash":"26182a523926188400188b8cc668585f8e81586416fa2399fd3585b0c07ff86c"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_1818ffbf9ffezXCeRFVV3Jj01O.json":{"size":263,"mtime_ns":1780238536000000000,"hash":"bf2e860eaa6fa5051d4cb0f70a53cf419b558c57759154e559712efef636dcb5"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_181f43997ffeO8mkoWfZl8v2Ri.json":{"size":214,"mtime_ns":1780236423000000000,"hash":"8252a79ead22a73fe0e229ef4eb6e1aac159547cdc04607d4102c75b077501a2"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_1823f3f66ffesCD5qohVVcWtbf.json":{"size":214,"mtime_ns":1780230242000000000,"hash":"27ba5fb0606f8ddda44c4f01ce5fbce77d7429f890317c6a1a6f9456058b1f70"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_183d2d323ffeBMoLOLwwPmAXym.json":{"size":214,"mtime_ns":1780210339000000000,"hash":"26814a22a2f0ee80b65a7306c4f52becca5edb09358cc12b588efa896546f571"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_183f72fc4ffeCZI7jMs3P9gB5n.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"cd6c278503c190ec5dae59675957e9a963cbd94ce09cf974b838a0e717b42817"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_184f894aeffej3cvErXgdYFhY8.json":{"size":263,"mtime_ns":1780201410000000000,"hash":"d3bb6c88a4c332c09d2684c3a34f10e311620956a8777a894cb1492ab15cce86"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_186a13679ffenULQIK102x2NfS.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"eb2fe454abea5ef36854bc2858ba6e9ac5e8f4d18e0121901f007816e0e40ebe"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_187ac729affeBH1jDFb5X7rWz0.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"b571d2be1c871201f1d572c536b57c89a277b0b3eb3f3a3e4125db5008b37a3f"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_18880f06fffeJSijqM7oWZ6JYI.json":{"size":214,"mtime_ns":1780201410000000000,"hash":"efb86e46a8d0c2af314450407bfacd3f422ec8f68025291a440760cffe4ca092"},"/Users/wuzilong/work/00code/shanxia2.0/.sisyphus/run-continuation/ses_1892f6be2ffe1K80MWGl0oc2Fo.json":{"size":214,"mtime_ns":1780114611000000000,"hash":"9b109fac4a24678ea27b4afe2840bcd06c339b490508d6aa531e051fe3f034b7"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/__init__.py":{"size":21,"mtime_ns":1780113982000000000,"hash":"5d7c80dd077475acb7eb12227dc0e7cf38bbb5fd9e84c4eab9eb2be8c5406357"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/__init__.py":{"size":665,"mtime_ns":1780804649000000000,"hash":"349c40e55e8aae86d0913a17da2f9d338d842243e5fdf807bad52382bc2876d9"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/admin.py":{"size":50215,"mtime_ns":1784993067000000000,"hash":"1ffbc4a47b09ca2442d99fab39f02b374fd30dee950be8103bee84444e2b376a"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/__init__.py":{"size":827,"mtime_ns":1784980394000000000,"hash":"7605bb52b5361261c5e04d52bec9a1e704b49dd4ffe5c561da079831e8d8db9e"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/advance.py":{"size":13404,"mtime_ns":1784992736000000000,"hash":"7e4bacb178dde4fddc158713abe0eb9e8ab6fedf57e571701e45a5af3189f858"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/attachment.py":{"size":5758,"mtime_ns":1782790898000000000,"hash":"f2d20fc5b2a98748835ae1710666f11eae1aa323d80a4678189bb2d032559b4b"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/api/bx/expense_item.py":{"size":3362,"mtime_ns":1782650913000000000,"hash":"142f0f6a3e45cb5d7b8153ee3048c5fb82258167264f325dd4fa8801b2e56c35"},"/Users/wuzilong/work/00code/shanxia2.0/backend/app/apLine truncated
|
||||
+5122
-1245
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
/Users/zlwu/work/00code/shanxia2.0/web-admin
|
||||
/Users/wuzilong/work/00code/shanxia2.0/web-admin
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+2854
-2082
File diff suppressed because it is too large.
Load diff
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "label": "crypto.ts", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L1"}, {"id": "http_crypto_encenvelope", "label": "EncEnvelope", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L1"}, {"id": "http_crypto_bytestobase64", "label": "bytesToBase64()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L8"}, {"id": "http_crypto_base64tobytes", "label": "base64ToBytes()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L16"}, {"id": "http_crypto_tobase64url", "label": "toBase64Url()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L25"}, {"id": "http_crypto_frombase64url", "label": "fromBase64Url()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L29"}, {"id": "http_crypto_hextobytes", "label": "hexToBytes()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L35"}, {"id": "http_crypto_sha256hex", "label": "sha256Hex()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L43"}, {"id": "http_crypto_randomnonce", "label": "randomNonce()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L52"}, {"id": "http_crypto_derivekey", "label": "deriveKey()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L59"}, {"id": "http_crypto_encryptjson", "label": "encryptJson()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L68"}, {"id": "http_crypto_decryptjson", "label": "decryptJson()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L81"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_encenvelope", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L1", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_bytestobase64", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L8", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_base64tobytes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L16", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_tobase64url", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L25", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_frombase64url", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L29", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_hextobytes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L35", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_sha256hex", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L43", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_randomnonce", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/http/crypto.ts", "source_location": "L52", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_crypto_ts", "target": "http_crypto_derivekey", "relation": "contains", "confidence": "EXTRACTED", "source_fLine truncated
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_views_bx_invoice_wallet_admin_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/bx/invoice-wallet/admin/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L1"}, {"id": "pl_plan_list_cert_cell_index_trimmed", "label": "trimmed", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L147"}, {"id": "pl_plan_list_cert_cell_index_base", "label": "base", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L150"}, {"id": "pl_plan_list_cert_cell_index_name", "label": "name", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L159"}, {"id": "pl_plan_list_cert_cell_index_normalized", "label": "normalized", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L165"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "icons_vue", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L104", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_pl_index_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L105", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "pl_index_planproduct", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L105", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_attachment_upload_field_index_vue", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L106", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_composables_usepltoxicproductcertcheck_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L107", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "composables_usepltoxicproductcertcheck_getproductcerturl", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L107", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "composables_usepltoxicproductcertcheck_getrequiredcertslots", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L107", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "composables_usepltoxicproductcertcheck_ishazardproduct", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-cert-cell/index.vue", "source_location": "L107", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_pl_plan_list_cert_cell_index_vue", "target": "composables_usepltoxicproductcertcheck_isproductcertslotfilled", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-list-certLine truncated
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_views_crm_opportunity_detail_vue", "label": "detail.vue", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/opportunity/detail.vue", "source_location": "L1"}, {"id": "opportunity_detail_goback", "label": "goBack()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/opportunity/detail.vue", "source_location": "L28"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_views_crm_opportunity_detail_vue", "target": "opportunity_detail_goback", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/opportunity/detail.vue", "source_location": "L28", "weight": 1.0}], "raw_calls": [{"caller_nid": "opportunity_detail_goback", "callee": "push", "is_member_call": true, "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/crm/opportunity/detail.vue", "source_location": "L29"}]}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "label": "validator.ts", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L1"}, {"id": "form_validator_passwordstrength", "label": "PasswordStrength", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L36"}, {"id": "form_validator_trimspaces", "label": "trimSpaces()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L47"}, {"id": "form_validator_validatephone", "label": "validatePhone()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L59"}, {"id": "form_validator_validatetelphone", "label": "validateTelPhone()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L74"}, {"id": "form_validator_validateaccount", "label": "validateAccount()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L90"}, {"id": "form_validator_validatepassword", "label": "validatePassword()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L106"}, {"id": "form_validator_validatestrongpassword", "label": "validateStrongPassword()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L131"}, {"id": "form_validator_getpasswordstrength", "label": "getPasswordStrength()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L158"}, {"id": "form_validator_validateipv4address", "label": "validateIPv4Address()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L190"}, {"id": "form_validator_validateemail", "label": "validateEmail()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L215"}, {"id": "form_validator_validateurl", "label": "validateURL()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L234"}, {"id": "form_validator_validatechineseidcard", "label": "validateChineseIDCard()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L252"}, {"id": "form_validator_validatebankcard", "label": "validateBankCard()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L285"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "target": "form_validator_passwordstrength", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L36", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "target": "form_validator_trimspaces", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L47", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "target": "form_validator_validatephone", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L59", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "target": "form_validator_validatetelphone", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L74", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "target": "form_validator_validateaccount", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L90", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "target": "form_validator_validatepassword", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/form/validator.ts", "source_location": "L106", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_form_validator_ts", "targLine truncated
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_dbplanrelatedviewdialog_vue", "label": "DbPlanRelatedViewDialog.vue", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/db/DbPlanRelatedViewDialog.vue", "source_location": "L1"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_dbplanrelatedviewdialog_vue", "target": "icons_vue", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/db/DbPlanRelatedViewDialog.vue", "source_location": "L23", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_dbplanrelatedviewdialog_vue", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_system_view_dialog_index_vue", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/db/DbPlanRelatedViewDialog.vue", "source_location": "L24", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_dbplanrelatedviewdialog_vue", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_db_plans_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/db/DbPlanRelatedViewDialog.vue", "source_location": "L25", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_dbplanrelatedviewdialog_vue", "target": "db_plans_planrelateddata", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/db/DbPlanRelatedViewDialog.vue", "source_location": "L25", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_dbplanrelatedviewdialog_vue", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_db_related_view_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/db/DbPlanRelatedViewDialog.vue", "source_location": "L26", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_db_dbplanrelatedviewdialog_vue", "target": "db_db_related_view_buildplanrelatedsections", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/db/DbPlanRelatedViewDialog.vue", "source_location": "L26", "weight": 1.0}], "raw_calls": []}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "label": "table-summary.ts", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L1"}, {"id": "bx_table_summary_summarycolumn", "label": "SummaryColumn", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L3"}, {"id": "bx_table_summary_pagesummaryparams", "label": "PageSummaryParams", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L8"}, {"id": "bx_table_summary_buildpagesummaryoptions", "label": "BuildPageSummaryOptions", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L13"}, {"id": "bx_table_summary_sumfield", "label": "sumField()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L23"}, {"id": "bx_table_summary_sumfieldbybiztype", "label": "sumFieldByBizType()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L37"}, {"id": "bx_table_summary_buildpagesummarymethod", "label": "buildPageSummaryMethod()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L56"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_format_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L1", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "bx_format_formatsummoney", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L1", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "bx_table_summary_summarycolumn", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L3", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "bx_table_summary_pagesummaryparams", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L8", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "bx_table_summary_buildpagesummaryoptions", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L13", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "bx_table_summary_sumfield", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L23", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "bx_table_summary_sumfieldbybiztype", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L37", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_bx_table_summary_ts", "target": "bx_table_summary_buildpagesummarymethod", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L56", "weight": 1.0}], "raw_calls": [{"caller_nid": "bx_table_summary_sumfield", "callee": "reduce", "is_member_call": true, "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L28"}, {"caller_nid": "bx_table_summary_sumfield", "callee": "formatSumMoney", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L33"}, {"caller_nid": "bx_table_summary_sumfieldbybiztype", "callee": "Number", "is_member_call": false, "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/utils/bx/table-summary.ts", "source_location": "L45"}, {"caller_nid": "bx_table_summary_sumfieldbybiztype", "callee": "formatSumMoney", "is_member_call": false, "source_file": "/Users/zlwuLine truncated
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "label": "customerPool.ts", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L1"}, {"id": "crm_customerpool_fetchpoolcustomerlist", "label": "fetchPoolCustomerList()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L3"}, {"id": "crm_customerpool_fetchclaimcustomer", "label": "fetchClaimCustomer()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L7"}, {"id": "crm_customerpool_fetchdistributecustomer", "label": "fetchDistributeCustomer()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L11"}, {"id": "crm_customerpool_fetchintopool", "label": "fetchIntoPool()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L15"}, {"id": "crm_customerpool_fetchpooloverview", "label": "fetchPoolOverview()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L19"}, {"id": "crm_customerpool_fetchpoolbyreason", "label": "fetchPoolByReason()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L23"}, {"id": "crm_customerpool_fetchpoolbylevel", "label": "fetchPoolByLevel()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L27"}, {"id": "crm_customerpool_fetchpoolduration", "label": "fetchPoolDuration()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L31"}, {"id": "crm_customerpool_fetchpoolclaimranking", "label": "fetchPoolClaimRanking()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L35"}, {"id": "crm_customerpool_fetchpooltrend", "label": "fetchPoolTrend()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L39"}, {"id": "crm_customerpool_fetchexportpoolstatistics", "label": "fetchExportPoolStatistics()", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L43"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "target": "users_zlwu_work_00code_shanxia2_0_web_admin_src_utils_http_index_ts", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L1", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "target": "crm_customerpool_fetchpoolcustomerlist", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L3", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "target": "crm_customerpool_fetchclaimcustomer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L7", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "target": "crm_customerpool_fetchdistributecustomer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L11", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "target": "crm_customerpool_fetchintopool", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L15", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "target": "crm_customerpool_fetchpooloverview", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L19", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_api_crm_customerpool_ts", "target": "crm_customerpool_fetchpoolbyreason", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/crm/customerPool.ts", "source_location": "L23", "weight": 1.0}, {"source": "users_zlwu_work_00code_shLine truncated
|
||||
Loaded 100 of 988 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user