zlwu před 3 měsíci
rodič
revize
33c038b8d9
37 změnil soubory, kde provedl 722 přidání a 0 odebrání
  1. 193 0
      backend/app/api/pl/transport_address.py
  2. 27 0
      backend/app/schemas/pl/datetime_parse.py
  3. 59 0
      backend/app/schemas/pl/transport_address.py
  4. 0 0
      backend/graphify-out/cache/ast/1faf6ba6ed11ed2c59fde858de3229e72e40d697a8e1cb072cd4f3c9cac71f85.json
  5. 0 0
      backend/graphify-out/cache/ast/2efa861b173dff9ace9fc1302668c6fb0b9133035b620652dba973de916cf5bb.json
  6. 0 0
      backend/graphify-out/cache/ast/3a8db4841d31e8ac11cddd835c587a95e9ecba49f182c41f472202bdf95ddc7e.json
  7. 0 0
      backend/graphify-out/cache/ast/3ae2c413c10898a8adb3d6773870bafbfbc18c739c10e0e8b4f765b4b5ca6c08.json
  8. 0 0
      backend/graphify-out/cache/ast/3b85bb35bf21499b273af55e19b6b3881f25cee6ec01ae0535127011e025a31f.json
  9. 1 0
      backend/graphify-out/cache/ast/45ac3c724e847705627547cf445804bba03009398ef13ff261827739bc6d052c.json
  10. 0 0
      backend/graphify-out/cache/ast/4fc5d8c71b1436b49694727543255d3849bbb7ea07c11c7dd858edf58f613b66.json
  11. 0 0
      backend/graphify-out/cache/ast/55354fbca3b24b8cc0791fc0db988ae5620d97189a041f39958c72d04cad43fe.json
  12. 0 0
      backend/graphify-out/cache/ast/64d8bf35c8fbf1f3919838c8f6c7bda0f259ef74e02d9a13e11724ce3f479a3c.json
  13. 0 0
      backend/graphify-out/cache/ast/6548b4b0c8c1d8096ce96ff1d5b523c67b12300f9a3eac66570b0256478a1120.json
  14. 0 0
      backend/graphify-out/cache/ast/68967e1a80fe2ead875d6a34f4eeedeaa043e08f3ec0036129f5969816a1b6ab.json
  15. 0 0
      backend/graphify-out/cache/ast/76d074f72ca8fe57ddf5255cbfeaeb0617f56c38313c125bdb44ee26fa478935.json
  16. 0 0
      backend/graphify-out/cache/ast/782b5634f6c11ef310f8fd0c20075c853e8b528bbf299f6f8ee4b60eb01e36e9.json
  17. 0 0
      backend/graphify-out/cache/ast/7a4aed900b956481914b4cabed2d855b9128cfb29af555c1039c0841bd4835c1.json
  18. 0 0
      backend/graphify-out/cache/ast/7a4eac85768cbeb0ba76f7f478ddadeeacab2498cd9be4070a76e1cfad693cdf.json
  19. 0 0
      backend/graphify-out/cache/ast/9f1fadeaa49db26a0bcc036cc7c2da0183ba1f2e11cf3d2f96eb3f98560ffacd.json
  20. 0 0
      backend/graphify-out/cache/ast/9f2ee5c92d10beb02a33ba48d29f7e2ffdb24d6c9a71529aa9b8adc8b0b266ab.json
  21. 0 0
      backend/graphify-out/cache/ast/a5e74057222e3359f6077b2fd05b2db4d058ec2702243cc449bff8d448d44a05.json
  22. 0 0
      backend/graphify-out/cache/ast/a5f249edb8e26331ef8a3b218505ff63ae32908d45c8fcac568fae1aafeae503.json
  23. 0 0
      backend/graphify-out/cache/ast/b04c894c08d023a8859efe68e3a14a6cf1dc3d10bca0fdf6a1615ee88a2e32ab.json
  24. 0 0
      backend/graphify-out/cache/ast/b22b57b7386bd377244de5278b5dcd9f1947ef3bb39060c6b7232e577e20dc8b.json
  25. 0 0
      backend/graphify-out/cache/ast/b6013d1cc5c15f58a14d457699d5fb495d6cb5434b2efaf947089d9375481025.json
  26. 0 0
      backend/graphify-out/cache/ast/be8cdc5ad527073abc08a9792b243ca6f68f11bb870aa8df3fb5c3f8bf1498a5.json
  27. 0 0
      backend/graphify-out/cache/ast/de577cd2c37c544c57063c81a2bbb656d22f6d8dedae7ef6360c59a675ed93d3.json
  28. 0 0
      backend/graphify-out/cache/ast/e304fadb044a6f396b3ce6320e63a742ce704dabc2aef99d3b574ce09ea94500.json
  29. 0 0
      backend/graphify-out/cache/ast/e654383417f6ea6c51506f1af1138c2ba7e048e7cbd419f881148fe4460ef22b.json
  30. 0 0
      backend/graphify-out/cache/ast/ec9fc66a2fbc240fdbb026349c2118d7b7243b61f789a22efdd51e0a0016c8f6.json
  31. 0 0
      backend/graphify-out/cache/ast/ff3d7179e1dbe74e16717a436a87222849d73c25bff26d86e4ab55265ce4f157.json
  32. 5 0
      docs/sql/migrations/2026-07-01_crm_product_price_unit_nullable.sql
  33. 6 0
      docs/sql/migrations/2026-07-01_pl_plan_address_coordinates.sql
  34. 15 0
      docs/sql/migrations/2026-07-01_pl_settlement_status_rejected.sql
  35. 22 0
      docs/sql/migrations/2026-07-01_pl_transport_address.sql
  36. 68 0
      docs/superpowers/plans/2026-06-30-portal-top-nav-only.md
  37. 326 0
      docs/superpowers/specs/2026-06-30-portal-top-nav-only-design.md

+ 193 - 0
backend/app/api/pl/transport_address.py

@@ -0,0 +1,193 @@
+"""运输通用地址库 API"""
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy import case, func, or_
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.db_auth import DbUserContext, get_db_current_user
+from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
+from app.core.tenant_context import get_tenant_no
+from app.core.utils import generate_uuid
+from app.models.pl_models import PlTransportAddress
+from app.schemas.pl.transport_address import (
+    TransportAddressCreate,
+    TransportAddressListResponse,
+    TransportAddressRead,
+    TransportAddressUpdate,
+)
+from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
+
+router = APIRouter(tags=["运输地址库"])
+
+
+def _address_to_read(row: PlTransportAddress) -> TransportAddressRead:
+    return TransportAddressRead.model_validate(row)
+
+
+def _get_address(
+    db: Session, tenant_no: str, address_id: str, user: DbUserContext
+) -> PlTransportAddress:
+    row = (
+        db.query(PlTransportAddress)
+        .filter(
+            PlTransportAddress.id == address_id,
+            PlTransportAddress.tenant_no == tenant_no,
+            PlTransportAddress.del_flag == 0,
+        )
+        .first()
+    )
+    if not row:
+        raise HTTPException(status_code=404, detail="地址不存在")
+    assert_pl_row_in_scope(db, row, PlTransportAddress, user, tenant_no)
+    return row
+
+
+def _build_address_query(db: Session, tenant_no: str, user: DbUserContext):
+    query = db.query(PlTransportAddress).filter(
+        PlTransportAddress.tenant_no == tenant_no,
+        PlTransportAddress.del_flag == 0,
+    )
+    query = apply_pl_scope(query, PlTransportAddress, user, db, tenant_no)
+    return query
+
+
+@router.get("/address", response_model=TransportAddressListResponse)
+def list_transport_addresses(
+    q: Optional[str] = Query(default=None, description="搜索名称或地址"),
+    status: Optional[str] = Query(default=None, description="状态 1-启用 0-停用"),
+    page: int = Query(default=1, ge=1),
+    page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> TransportAddressListResponse:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    query = _build_address_query(db, tenant_no, user)
+
+    if status:
+        query = query.filter(PlTransportAddress.status == status)
+
+    if q:
+        keyword = q.strip().lower()
+        query = query.filter(
+            or_(
+                func.lower(PlTransportAddress.address_name).contains(keyword),
+                func.lower(PlTransportAddress.full_address).contains(keyword),
+                func.lower(PlTransportAddress.contact_name).contains(keyword),
+                PlTransportAddress.contact_phone.contains(q.strip()),
+            )
+        )
+
+    query = query.order_by(
+        case((PlTransportAddress.order_num.is_(None), 1), else_=0).asc(),
+        PlTransportAddress.create_time.desc(),
+    )
+
+    total = query.count()
+    offset = (page - 1) * page_size
+    rows = query.offset(offset).limit(page_size).all()
+    return TransportAddressListResponse(
+        total=total,
+        list=[_address_to_read(row) for row in rows],
+    )
+
+
+@router.get("/address/all", response_model=TransportAddressListResponse)
+def list_all_transport_addresses(
+    q: Optional[str] = Query(default=None, description="搜索名称或地址"),
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> TransportAddressListResponse:
+    """选择器用:仅返回启用中的地址(不分页)。"""
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    query = _build_address_query(db, tenant_no, user).filter(PlTransportAddress.status == "1")
+
+    if q:
+        keyword = q.strip().lower()
+        query = query.filter(
+            or_(
+                func.lower(PlTransportAddress.address_name).contains(keyword),
+                func.lower(PlTransportAddress.full_address).contains(keyword),
+            )
+        )
+
+    query = query.order_by(
+        case((PlTransportAddress.order_num.is_(None), 1), else_=0).asc(),
+        PlTransportAddress.create_time.desc(),
+    )
+    rows = query.limit(200).all()
+    return TransportAddressListResponse(total=len(rows), list=[_address_to_read(row) for row in rows])
+
+
+@router.post("/address", response_model=TransportAddressRead, status_code=status.HTTP_201_CREATED)
+def create_transport_address(
+    payload: TransportAddressCreate,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> TransportAddressRead:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    row = PlTransportAddress(
+        id=generate_uuid(),
+        tenant_no=tenant_no,
+        address_name=payload.address_name.strip(),
+        full_address=payload.full_address.strip(),
+        lng=payload.lng,
+        lat=payload.lat,
+        contact_name=payload.contact_name,
+        contact_phone=payload.contact_phone,
+        status=payload.status or "1",
+        remark=payload.remark,
+        create_by=user.id,
+        create_dept_id=user.dept_id,
+    )
+    db.add(row)
+    db.commit()
+    db.refresh(row)
+    return _address_to_read(row)
+
+
+@router.get("/address/{id}", response_model=TransportAddressRead)
+def get_transport_address(
+    id: str,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> TransportAddressRead:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    row = _get_address(db, tenant_no, id, user)
+    return _address_to_read(row)
+
+
+@router.put("/address/{id}", response_model=TransportAddressRead)
+def update_transport_address(
+    id: str,
+    payload: TransportAddressUpdate,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> TransportAddressRead:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    row = _get_address(db, tenant_no, id, user)
+
+    for key, value in payload.model_dump(exclude_unset=True).items():
+        if key in ("address_name", "full_address") and isinstance(value, str):
+            value = value.strip()
+        setattr(row, key, value)
+
+    row.update_by = user.id
+    db.commit()
+    db.refresh(row)
+    return _address_to_read(row)
+
+
+@router.delete("/address/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_transport_address(
+    id: str,
+    db: Session = Depends(get_db),
+    user: DbUserContext = Depends(get_db_current_user),
+) -> None:
+    tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    row = _get_address(db, tenant_no, id, user)
+    assert_row_deletable_by_creator(row, user_info_from_context(user))
+    row.del_flag = 1
+    row.update_by = user.id
+    db.commit()

+ 27 - 0
backend/app/schemas/pl/datetime_parse.py

@@ -0,0 +1,27 @@
+"""运输计划相关日期时间解析:前端 date-picker 仅传 YYYY-MM-DD。"""
+from __future__ import annotations
+
+from datetime import date, datetime, time
+from typing import Annotated, Optional
+
+from pydantic import BeforeValidator
+
+
+def parse_optional_plan_datetime(value: object) -> Optional[datetime]:
+    if value is None:
+        return None
+    if isinstance(value, datetime):
+        return value
+    if isinstance(value, date):
+        return datetime.combine(value, time.min)
+    if isinstance(value, str):
+        text = value.strip()
+        if not text:
+            return None
+        if len(text) == 10 and text[4] == "-" and text[7] == "-":
+            return datetime.strptime(text, "%Y-%m-%d")
+        return datetime.fromisoformat(text.replace("Z", "+00:00"))
+    raise ValueError("invalid datetime")
+
+
+OptionalPlanDateTime = Annotated[Optional[datetime], BeforeValidator(parse_optional_plan_datetime)]

+ 59 - 0
backend/app/schemas/pl/transport_address.py

@@ -0,0 +1,59 @@
+"""运输通用地址库 Schema"""
+from datetime import datetime
+from decimal import Decimal
+from typing import Optional
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class TransportAddressBase(BaseModel):
+    address_name: str = Field(..., max_length=200, alias="addressName", description="地址名称")
+    full_address: str = Field(..., max_length=500, alias="fullAddress", description="详细地址")
+    lng: Decimal = Field(..., description="经度")
+    lat: Decimal = Field(..., description="纬度")
+    contact_name: Optional[str] = Field(default=None, max_length=100, alias="contactName", description="联系人")
+    contact_phone: Optional[str] = Field(default=None, max_length=32, alias="contactPhone", description="联系电话")
+    status: str = Field(default="1", max_length=1, description="状态 1-启用 0-停用")
+    remark: Optional[str] = Field(default=None, description="备注")
+
+    model_config = {"populate_by_name": True}
+
+
+class TransportAddressCreate(TransportAddressBase):
+    @model_validator(mode="after")
+    def validate_coordinates(self) -> "TransportAddressCreate":
+        if self.lng is None or self.lat is None:
+            raise ValueError("请通过地图选点填写经纬度")
+        return self
+
+
+class TransportAddressUpdate(BaseModel):
+    address_name: Optional[str] = Field(default=None, max_length=200, alias="addressName")
+    full_address: Optional[str] = Field(default=None, max_length=500, alias="fullAddress")
+    lng: Optional[Decimal] = None
+    lat: Optional[Decimal] = None
+    contact_name: Optional[str] = Field(default=None, max_length=100, alias="contactName")
+    contact_phone: Optional[str] = Field(default=None, max_length=32, alias="contactPhone")
+    status: Optional[str] = Field(default=None, max_length=1)
+    remark: Optional[str] = None
+
+    model_config = {"populate_by_name": True}
+
+
+class TransportAddressRead(TransportAddressBase):
+    id: str
+    tenant_no: str = Field(alias="tenantNo")
+    order_num: Optional[int] = Field(default=None, alias="orderNum")
+    create_by: Optional[str] = Field(default=None, alias="createBy")
+    create_dept_id: Optional[str] = Field(default=None, alias="createDeptId")
+    create_time: datetime = Field(alias="createTime")
+    update_by: Optional[str] = Field(default=None, alias="updateBy")
+    update_time: datetime = Field(alias="updateTime")
+    del_flag: int = Field(default=0, alias="delFlag")
+
+    model_config = {"from_attributes": True, "populate_by_name": True}
+
+
+class TransportAddressListResponse(BaseModel):
+    total: int
+    list: list[TransportAddressRead]

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/1faf6ba6ed11ed2c59fde858de3229e72e40d697a8e1cb072cd4f3c9cac71f85.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/2efa861b173dff9ace9fc1302668c6fb0b9133035b620652dba973de916cf5bb.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/3a8db4841d31e8ac11cddd835c587a95e9ecba49f182c41f472202bdf95ddc7e.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/3ae2c413c10898a8adb3d6773870bafbfbc18c739c10e0e8b4f765b4b5ca6c08.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/3b85bb35bf21499b273af55e19b6b3881f25cee6ec01ae0535127011e025a31f.json


+ 1 - 0
backend/graphify-out/cache/ast/45ac3c724e847705627547cf445804bba03009398ef13ff261827739bc6d052c.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_backend_app_api_pl_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_backend_app_api_pl_init_py", "target": "fastapi", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_api_pl_init_py", "target": "app_api_pl", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_zlwu_work_00code_shanxia2_0_backend_app_api_pl_init_py", "target": "app_api_pl", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/__init__.py", "source_location": "L5", "weight": 1.0}], "raw_calls": []}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/4fc5d8c71b1436b49694727543255d3849bbb7ea07c11c7dd858edf58f613b66.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/55354fbca3b24b8cc0791fc0db988ae5620d97189a041f39958c72d04cad43fe.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/64d8bf35c8fbf1f3919838c8f6c7bda0f259ef74e02d9a13e11724ce3f479a3c.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/6548b4b0c8c1d8096ce96ff1d5b523c67b12300f9a3eac66570b0256478a1120.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/68967e1a80fe2ead875d6a34f4eeedeaa043e08f3ec0036129f5969816a1b6ab.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/76d074f72ca8fe57ddf5255cbfeaeb0617f56c38313c125bdb44ee26fa478935.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/782b5634f6c11ef310f8fd0c20075c853e8b528bbf299f6f8ee4b60eb01e36e9.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/7a4aed900b956481914b4cabed2d855b9128cfb29af555c1039c0841bd4835c1.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/7a4eac85768cbeb0ba76f7f478ddadeeacab2498cd9be4070a76e1cfad693cdf.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/9f1fadeaa49db26a0bcc036cc7c2da0183ba1f2e11cf3d2f96eb3f98560ffacd.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/9f2ee5c92d10beb02a33ba48d29f7e2ffdb24d6c9a71529aa9b8adc8b0b266ab.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/a5e74057222e3359f6077b2fd05b2db4d058ec2702243cc449bff8d448d44a05.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/a5f249edb8e26331ef8a3b218505ff63ae32908d45c8fcac568fae1aafeae503.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/b04c894c08d023a8859efe68e3a14a6cf1dc3d10bca0fdf6a1615ee88a2e32ab.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/b22b57b7386bd377244de5278b5dcd9f1947ef3bb39060c6b7232e577e20dc8b.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/b6013d1cc5c15f58a14d457699d5fb495d6cb5434b2efaf947089d9375481025.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/be8cdc5ad527073abc08a9792b243ca6f68f11bb870aa8df3fb5c3f8bf1498a5.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/de577cd2c37c544c57063c81a2bbb656d22f6d8dedae7ef6360c59a675ed93d3.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/e304fadb044a6f396b3ce6320e63a742ce704dabc2aef99d3b574ce09ea94500.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/e654383417f6ea6c51506f1af1138c2ba7e048e7cbd419f881148fe4460ef22b.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/ec9fc66a2fbc240fdbb026349c2118d7b7243b61f789a22efdd51e0a0016c8f6.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
backend/graphify-out/cache/ast/ff3d7179e1dbe74e16717a436a87222849d73c25bff26d86e4ab55265ce4f157.json


+ 5 - 0
docs/sql/migrations/2026-07-01_crm_product_price_unit_nullable.sql

@@ -0,0 +1,5 @@
+-- CRM 产品:标准价、成本价、计量单位改为可空(产品模块不再维护,由报价/合同行维护)
+ALTER TABLE `crm_product`
+  MODIFY COLUMN `unit` varchar(20) DEFAULT NULL COMMENT '单位',
+  MODIFY COLUMN `price` decimal(15,2) DEFAULT NULL COMMENT '标准单价',
+  MODIFY COLUMN `cost_price` decimal(15,2) DEFAULT NULL COMMENT '成本价';

+ 6 - 0
docs/sql/migrations/2026-07-01_pl_plan_address_coordinates.sql

@@ -0,0 +1,6 @@
+-- 运输计划取货/送货地址坐标(高德导航)
+ALTER TABLE `pl_plan`
+  ADD COLUMN `pickup_lng` decimal(10,6) DEFAULT NULL COMMENT '取货经度' AFTER `pickup_address`,
+  ADD COLUMN `pickup_lat` decimal(10,6) DEFAULT NULL COMMENT '取货纬度' AFTER `pickup_lng`,
+  ADD COLUMN `destination_lng` decimal(10,6) DEFAULT NULL COMMENT '送货经度' AFTER `destination_address`,
+  ADD COLUMN `destination_lat` decimal(10,6) DEFAULT NULL COMMENT '送货纬度' AFTER `destination_lng`;

+ 15 - 0
docs/sql/migrations/2026-07-01_pl_settlement_status_rejected.sql

@@ -0,0 +1,15 @@
+-- 运费结算:新增「已驳回」状态字典(审批驳回后 settlement_status = rejected)
+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
+  'PL_SETTLE_REJECTED', '已驳回', 'rejected', 'danger', 'pl_settlement_status', t.tenant_no, 2,
+  NULL, 'system', '', NOW(), NULL, NOW(), 0
+FROM (
+  SELECT DISTINCT `tenant_no` FROM `sys_dict_type` WHERE `dict_type` = 'pl_settlement_status' AND `del_flag` = 0
+) t
+WHERE NOT EXISTS (
+  SELECT 1 FROM `sys_dict_data` d
+  WHERE d.id = 'PL_SETTLE_REJECTED' AND d.tenant_no = t.tenant_no AND d.del_flag = 0
+);

+ 22 - 0
docs/sql/migrations/2026-07-01_pl_transport_address.sql

@@ -0,0 +1,22 @@
+-- 运输通用地址库
+CREATE TABLE IF NOT EXISTS `pl_transport_address` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `address_name` varchar(200) NOT NULL COMMENT '地址名称',
+  `full_address` varchar(500) NOT NULL COMMENT '详细地址',
+  `lng` decimal(10,6) NOT NULL COMMENT '经度',
+  `lat` decimal(10,6) NOT NULL COMMENT '纬度',
+  `contact_name` varchar(100) DEFAULT NULL COMMENT '联系人',
+  `contact_phone` varchar(32) DEFAULT NULL COMMENT '联系电话',
+  `status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 1-启用 0-停用',
+  `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_transport_address_tenant` (`tenant_no`, `del_flag`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输通用地址库';

+ 68 - 0
docs/superpowers/plans/2026-06-30-portal-top-nav-only.md

@@ -0,0 +1,68 @@
+# Portal 顶栏全量导航 — 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:** 去掉 Portal 左侧栏,一级模块改为顶栏下拉,二三级改为顶栏水平菜单(ellipsis),≤800px 用抽屉导航。
+
+**Architecture:** 复用 `filterPortalTopModules` / `filterSidebarRoutes` 与 `ArtHorizontalMenu`;新建 `ArtPortalModuleSelect`、`ArtPortalSubNav`、`ArtPortalMobileMenu`;布局仅保留 `#app-workspace` 全宽;WorkTab 空标签落点按当前一级模块 children 解析。
+
+**Tech Stack:** Vue 3 · Element Plus · Pinia · vue-router · SCSS
+
+**Spec:** `docs/superpowers/specs/2026-06-30-portal-top-nav-only-design.md`
+
+---
+
+### Task 1: Portal 菜单工具函数
+
+**Files:**
+- Modify: `web-admin/src/utils/router/portal-menu.ts`
+- Modify: `web-admin/src/utils/navigation/route.ts`
+
+- [ ] **Step 1:** 在 `portal-menu.ts` 增加 `resolvePortalSubMenus(menuList, routePath)`、`getPortalDefaultPathForRoute(menuList, routePath)`
+- [ ] **Step 2:** 在 `route.ts` 增加 `getFirstSecondLevelMenuPathForModule(moduleChildren, modulePath?)` 导出
+
+### Task 2: 水平菜单支持无图标
+
+**Files:**
+- Modify: `web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue`
+- Modify: `web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue`
+
+- [ ] **Step 1:** `HorizontalSubmenu` 增加 `showIcon` prop(默认 `true`)
+- [ ] **Step 2:** `ArtHorizontalMenu` 增加 `showIcon` prop 并向下传递
+
+### Task 3: 新建顶栏导航组件
+
+**Files:**
+- Create: `web-admin/src/components/core/layouts/art-portal-module-select/index.vue`
+- Create: `web-admin/src/components/core/layouts/art-portal-sub-nav/index.vue`
+- Create: `web-admin/src/components/core/layouts/art-portal-mobile-menu/index.vue`
+
+- [ ] **Step 1:** 一级下拉 `el-dropdown` + 模块切换
+- [ ] **Step 2:** 二三级 `ArtHorizontalMenu` + `ellipsis` + Fresh Mint 样式
+- [ ] **Step 3:** ≤800px `el-drawer` + `SidebarSubmenu` 竖向树
+
+### Task 4: 组装顶栏与布局
+
+**Files:**
+- Modify: `web-admin/src/components/core/layouts/art-portal-header/index.vue`
+- Modify: `web-admin/src/views/index/index.vue`
+- Modify: `web-admin/src/views/index/style.portal.scss`
+- Modify: `web-admin/src/assets/styles/core/app.scss`
+
+- [ ] **Step 1:** Header 替换 TopNav 为 ModuleSelect + SubNav + MobileMenu
+- [ ] **Step 2:** Portal 布局移除 `#app-sidebar`
+- [ ] **Step 3:** SCSS 全宽 workspace,删除侧栏规则
+
+### Task 5: WorkTab 联动
+
+**Files:**
+- Modify: `web-admin/src/store/modules/worktab.ts`
+- Modify: `web-admin/src/components/core/layouts/art-work-tab/index.vue`
+
+- [ ] **Step 1:** 空标签落点改用 `getPortalDefaultPathForRoute`
+
+### Task 6: 验证
+
+- [ ] **Step 1:** `pnpm lint`(web-admin)
+- [ ] **Step 2:** `./scripts/update-graph.sh frontend`
+- [ ] **Step 3:** 浏览器手测:模块切换、ellipsis、800px 抽屉

+ 326 - 0
docs/superpowers/specs/2026-06-30-portal-top-nav-only-design.md

@@ -0,0 +1,326 @@
+# web-admin 门户布局 — 顶栏全量导航改造设计
+
+> **日期**: 2026-06-30  
+> **状态**: 实施中  
+> **范围**: `web-admin` 管理端框架(Portal 壳层布局 + 菜单展示 + 导航联动)  
+> **前置文档**: `2026-06-20-web-admin-portal-layout-design.md`(已实现:顶栏一级卡片 + 左侧二三级)  
+> **UI 规范**: `docs/web-admin-ui-spec-fresh-mint.md`(主色 `#10b981`、Fresh Mint Token)
+
+## 背景与目标
+
+客户希望 **去掉左侧菜单栏**,将全部导航收拢到顶栏:
+
+1. 顶栏 **第一位**(品牌区之后)为 **一级模块下拉**(替代现有横向卡片)。
+2. 顶栏 **后续区域** 展示当前一级模块对应的 **二级、三级** 水平菜单。
+3. 内容区 **全宽**,WorkTab + 业务页面逻辑不变。
+4. 顶栏保持 **单行 56px**,菜单过多时使用 Element Plus 水平菜单 **`ellipsis`** 收进「更多」。
+
+**已选方案**:**方案 A — 单行顶栏 + ellipsis 溢出**(2026-06-30 确认)。
+
+---
+
+## 已确认决策
+
+| # | 决策 | 说明 |
+|---|------|------|
+| D1 | **布局形态** | 去掉 `#app-sidebar`;Portal 布局仅保留顶栏 + 全宽 workspace |
+| D2 | **一级展示** | `el-dropdown` 下拉选择模块(CRM / 报销 / 地磅 / 运输 / 工作流 / 系统),替代 `ArtPortalTopNav` 卡片 |
+| D3 | **二三级展示** | `ElMenu mode="horizontal"` + `ellipsis`,渲染当前模块 `children` |
+| D4 | **顶栏高度** | 保持 **56px**,不采用双行顶栏 |
+| D5 | **溢出策略** | 优先使用 EP 内置 `ellipsis`;必要时可补充横向 scroll(参考 `ArtMixedMenu`) |
+| D6 | **权限与路由** | 不改 `MenuProcessor`、路由注册、`menuCode` 过滤链路 |
+| D7 | **菜单图标** | 一级下拉项可带 icon;顶栏二三级水平菜单 **不展示图标**(延续 OpenSpec「更深层仅文字」语义) |
+| D8 | **控制台** | 仍从顶栏/导航中排除;默认首页逻辑不变 |
+| D9 | **旧布局** | `MenuTypeEnum.PORTAL` 壳层改造;LEFT / TOP / TOP_LEFT / DUAL 代码保留但不作为产品入口 |
+
+---
+
+## 非目标(本期不做)
+
+- 业务页面 `<script>`、API、权限后端改造
+- 路由结构(`sqxp.ts` / `pl.ts`)重组或新增中间目录层
+- 小程序端布局
+- 设置面板重新开放 Left / Top / Mixed 等旧布局切换
+- Demo 菜单独立提升为一级模块
+
+---
+
+## 一、目标布局
+
+```
+┌──────────────────────────────────────────────────────────────────────────────┐
+│ [Logo + 系统名] │ [一级模块 ▼] │ 二级A │ 二级B │ 二级C ▼ │ … │ [更多▼] │ [全屏][用户] │  ← 56px
+├──────────────────────────────────────────────────────────────────────────────┤
+│ ArtWorkTab(标签页)                                                           │
+├──────────────────────────────────────────────────────────────────────────────┤
+│ ArtPageContent(业务页面,全宽)                                                │
+└──────────────────────────────────────────────────────────────────────────────┘
+```
+
+**与现布局对比**
+
+| 项目 | 现 Portal(2026-06-20) | 本改造 |
+|------|-------------------------|--------|
+| 一级 | 顶栏横向卡片 | 顶栏第一位下拉 |
+| 二三级 | 左侧 `ArtSidebarMenu` | 顶栏水平菜单 |
+| 内容区 | 侧栏右侧,非全宽 | 全宽 |
+| 顶栏高度 | 56px | 56px(不变) |
+
+**DOM 结构(`views/index/index.vue`)**
+
+```
+.app-layout-portal
+├── #app-portal-header     → ArtPortalHeader(改造)
+└── #app-portal-body       → 仅 workspace,无 sidebar
+    └── #app-workspace     → flex column,width 100%
+        ├── #app-worktab   → ArtWorkTab
+        └── #app-content   → ArtPageContent
+```
+
+---
+
+## 二、顶栏分区(ArtPortalHeader)
+
+### 2.1 品牌区(保留)
+
+- `ArtLogo` + `AppConfig.systemInfo.name`
+- 点击跳转 `homePath`
+- 右侧竖线分隔(沿用现有 `portal-header-brand` 样式)
+
+### 2.2 一级模块下拉(新建 ArtPortalModuleSelect)
+
+**数据源**
+
+- `filterPortalTopModules(menuStore.menuList)`(`utils/router/portal-menu.ts`)
+- 顺序:`PORTAL_TOP_MODULE_ORDER`(CRM → 报销 → 地磅 → 运输 → 工作流 → 系统)
+- 排除控制台与 `meta.isHide`
+
+**UI**
+
+| 属性 | 值 |
+|------|-----|
+| 控件 | `el-dropdown`(trigger 为按钮,展示当前模块名 + 箭头) |
+| 高度 | 36px(与筛选控件高度节奏一致) |
+| 圆角 | 8px |
+| 下拉项 | 可选 icon + 模块名;当前模块高亮(主色或 `--primary-bg`) |
+| 空态 | 0 个可见模块时 trigger 展示「暂无可用模块」且禁用 |
+
+**交互**
+
+- 选择模块:`router.push(getFirstAccessiblePath(module))`
+- 若所选模块已是当前模块:不重复跳转
+- 当前模块解析:复用 `isPortalModuleActive(item, routePath)`
+
+### 2.3 二三级水平菜单(新建 ArtPortalSubNav)
+
+**数据源**
+
+```typescript
+const currentTopPath = `/${route.path.split('/')[1]}`
+const module = menuList.find((m) => m.path === currentTopPath)
+const subMenus = filterSidebarRoutes(module?.children ?? [])
+```
+
+**实现**
+
+- 包装现有 `ArtHorizontalMenu` + `HorizontalSubmenu`,或抽取共用递归组件
+- `ElMenu` 属性:`mode="horizontal"`、`ellipsis="true"`、`default-active` = 当前路由
+- 过滤:与侧栏一致,使用 `filterSidebarRoutes`
+- iframe 路由:沿用 `art-sidebar-menu` 中 `findIframeMenuList` 逻辑(迁移为 util 或在 SubNav 内复用)
+
+**菜单项行为**
+
+| 类型 | 渲染 | 点击 |
+|------|------|------|
+| 叶子(无可导航子节点) | `ElMenuItem` | `handleMenuJump(item)` |
+| 目录(有可见子节点) | `ElSubMenu` → 递归三级 | 展开下拉 |
+
+**视觉**
+
+- 菜单项字号 14px,选中态主色 `#10b981`(底边或背景 `--primary-bg`,与 Fresh Mint 顶栏菜单一致)
+- **不渲染** 二三级 icon(`HorizontalSubmenu` 需增加 `showIcon={false}` 或 Portal 专用 variant)
+- Popper / 下拉:`backdrop-filter: none`
+
+### 2.4 操作区(保留)
+
+- 全屏按钮(`shouldShowFullscreen`)
+- `ArtUserMenu`
+- 不恢复:折叠、面包屑、刷新
+
+### 2.5 顶栏 flex 分配
+
+```
+portal-header-brand   → flex-shrink: 0
+portal-module-select  → flex-shrink: 0(约 120–160px)
+portal-sub-nav        → flex: 1; min-width: 0(ellipsis 生效关键)
+portal-header-actions → flex-shrink: 0
+```
+
+---
+
+## 三、移除左侧栏
+
+### 3.1 布局
+
+- `views/index/index.vue`:Portal 分支删除 `<aside id="app-sidebar">` 与 `<ArtSidebarMenu />`
+- `style.portal.scss`:删除 `#app-sidebar` 及 `<800px` fixed 抽屉规则
+- `#app-portal-body`:由 `flex row` 改为单列;`#app-workspace` 占满宽度
+
+### 3.2 组件处置
+
+| 组件 | 处置 |
+|------|------|
+| `ArtPortalTopNav` | 废弃(卡片式一级),由 `ArtPortalModuleSelect` 替代 |
+| `ArtSidebarMenu` | Portal 模式下不再挂载;组件文件保留供非 PORTAL 布局或后续清理 |
+| `art-sidebar-menu` 中 PORTAL 分支 | 可删除或留空(仅 PORTAL 使用时) |
+
+---
+
+## 四、导航联动
+
+### 4.1 WorkTab 关闭全部标签
+
+现逻辑注释为「跳转侧栏第一个二级菜单」。改造后:
+
+- 解析 **当前一级模块** 的 `children`
+- 调用 `getFirstSecondLevelMenuPath(moduleChildren)` 或等价函数
+- 若无二级结构,回退 `getFirstAccessiblePath(activeModule)`
+
+**涉及文件**
+
+- `store/modules/worktab.ts`
+- `components/core/layouts/art-work-tab/index.vue`
+- 可选:在 `utils/navigation/route.ts` 新增 `getFirstSecondLevelMenuPathForModule(modulePath, menuList)`
+
+### 4.2 默认首页(homePath)
+
+- `menuStore.setHomePath` 逻辑可不变(仍从全量树取第一个二级)
+- 登录后若落在某模块,顶栏下拉与水平菜单选中态需与路由同步
+
+### 4.3 跨模块 deep link
+
+- 直接访问 `/pl/plan` 时:下拉显示「运输管理」;水平菜单高亮「运输计划」
+- 依赖 `isPortalModuleActive` + 水平菜单 `default-active`(`meta.activePath || path`)
+
+---
+
+## 五、菜单数据与权限(不变)
+
+沿用现有链路:
+
+```
+beforeEach → MenuProcessor.getMenuList()
+           → filterMenuByRoles / filterMenuByCodes
+           → filterEmptyMenus
+           → menuStore.setMenuList
+           → RouteRegistry.register
+```
+
+| 层级 | 过滤 |
+|------|------|
+| 一级(下拉) | `filterPortalTopModules` |
+| 二三级(水平) | `filterSidebarRoutes` + `HorizontalSubmenu` 递归 |
+
+三级权限、`menuCode`、非超管角色菜单可见性与现 Portal 一致。
+
+---
+
+## 六、响应式
+
+| 断点 | 策略 |
+|------|------|
+| ≥1180px | 单行顶栏;水平菜单 ellipsis 处理溢出 |
+| 800–1180px | 系统名可缩短 max-width;ellipsis 继续生效 |
+| ≤800px | **必须**提供可用导航:顶栏右侧增加「菜单」按钮,点击打开 **抽屉 / 全屏面板**,内嵌当前模块二三级树(可复用 `SidebarSubmenu` 竖向渲染) |
+
+窄屏下不可仅依赖 ellipsis(触控体验差),抽屉为 **本期必做**,非 follow-up。
+
+---
+
+## 七、样式要点
+
+| 项 | 值 |
+|----|-----|
+| 顶栏高度 | 56px |
+| 顶栏背景 | `var(--mint-surface, #fff)` + 底边 `var(--mint-border)` |
+| 主色 | `#10b981` / `#059669` / `#ecfdf5` |
+| 遮罩 | `backdrop-filter: none` |
+| 内容区 padding | 沿用 `--mint-content-padding`(24px) |
+| 侧栏宽度 | **0**(无侧栏) |
+
+全局 SCSS:更新 `app.scss` 中 `.app-layout-portal #app-sidebar` 相关规则(删除或注释)。
+
+---
+
+## 八、影响文件清单
+
+**新建**
+
+- `components/core/layouts/art-portal-module-select/index.vue` — 一级模块下拉
+- `components/core/layouts/art-portal-sub-nav/index.vue` — 二三级水平菜单
+- `components/core/layouts/art-portal-mobile-menu/index.vue`(可选独立文件)— ≤800px 抽屉菜单
+
+**修改**
+
+- `components/core/layouts/art-portal-header/index.vue` — 组装新顶栏
+- `components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue` — Portal 模式隐藏 icon(或 props 控制)
+- `views/index/index.vue` — 去掉 sidebar
+- `views/index/style.portal.scss` — 全宽 workspace
+- `assets/styles/core/app.scss` — 删除 Portal 侧栏兜底样式
+- `store/modules/worktab.ts` — 关闭全部标签后的落点
+- `components/core/layouts/art-work-tab/index.vue` — 同上
+- `utils/navigation/route.ts` — 可选:按模块取第一个二级路径
+
+**废弃 / 不再引用(Portal)**
+
+- `components/core/layouts/art-portal-top-nav/index.vue`
+
+**文档**
+
+- 更新 `2026-06-29-fresh-mint-master-plan.md` §二「Portal 顶栏 + 侧栏交互结构不变」→ 注明已被本 spec 取代
+
+---
+
+## 九、测试计划
+
+| 场景 | 预期 |
+|------|------|
+| super_admin | 下拉 6 个模块;切换后水平菜单切换为对应子树 |
+| admin 仅有 bx 权限 | 下拉仅「报销管理」;水平菜单仅 bx 子项 |
+| 无任何业务 menuCode | 下拉空态「暂无可用模块」 |
+| 切换 CRM → 报销 | 路由进入报销首个可访问页;水平菜单更新 |
+| CRM 模块 10+ 项 | 单行顶栏不撑破;溢出项进入「更多」 |
+| 含三级目录的菜单树 | 二级为 SubMenu,三级在下拉中可点击 |
+| 直接 URL `/pl/plan` | 下拉选中运输;水平菜单高亮对应项 |
+| WorkTab 关闭全部 | 跳转当前模块第一个二级菜单页 |
+| ≤800px 视口 | 「菜单」抽屉可打开并导航 |
+| 控制台 `/dashboard` | 不出现在下拉与水平菜单 |
+
+---
+
+## 十、风险
+
+1. **CRM 等模块项数多**:ellipsis 后部分菜单藏在「更多」中,需验收可发现性。
+2. **HorizontalSubmenu 默认带 icon**:需 Portal 专用样式/ props,避免违反 OpenSpec。
+3. **WorkTab 落点**:若仍读全量 `menuList` 取第一个二级,关闭标签可能跳到错误模块。
+4. **与 2026-06-20 spec 冲突**:属新产品决策,以本文档为准。
+5. **移动端抽屉**:若遗漏,小屏将无法正常切换页面。
+
+---
+
+## 十一、验收标准
+
+- [ ] Portal 布局下无左侧栏,内容区全宽
+- [ ] 顶栏 56px 单行,一级为下拉,二三级为水平菜单
+- [ ] 菜单权限、路由跳转、WorkTab 行为与改造前一致
+- [ ] Fresh Mint 色值与无 blur 遮罩符合 `web-admin-ui-spec-fresh-mint.md`
+- [ ] ≤800px 具备抽屉导航
+- [ ] 不影响非 Portal 布局代码路径(若仍保留)
+
+---
+
+## 十二、后续(非本期)
+
+- 清理 `ArtPortalTopNav`、Portal 专用侧栏 SCSS  dead code
+- OpenSpec `ui-conventions` 增加「Portal 顶栏全量导航」章节
+- 设置面板移除已隐藏的 layout 切换项

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů