提交
This commit is contained in:
1 parent
d08f03ab03
commit
cc843535f5
154 files changed
+17837
-5153
No files matched your search
@@ -13,13 +13,16 @@ from app.core.mobile_menu_guard import (
|
||||
)
|
||||
from app.core.response import error_response, success_response
|
||||
from app.schemas.market_mobile import (
|
||||
MarketAttachmentDownloadBody,
|
||||
MarketFreightCreateBody,
|
||||
MarketFreightItemsBody,
|
||||
MarketFreightUpdateBody,
|
||||
MarketPaymentTransferBody,
|
||||
MarketPaymentTransferCreateBody,
|
||||
MarketPresignOrderCreateBody,
|
||||
MarketPresignOrderBody,
|
||||
MarketPresignTransferBody,
|
||||
MarketProtocolAuditBody,
|
||||
MarketProtocolBody,
|
||||
MarketSampleInsuranceBody,
|
||||
MarketSampleStorageCertBody,
|
||||
@@ -105,6 +108,7 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None:
|
||||
async def presign_form_options(
|
||||
region_id: Optional[str] = Query(None, alias="regionId"),
|
||||
business_id: Optional[str] = Query(None, alias="businessId"),
|
||||
user_keyword: Optional[str] = Query(None, alias='userKeyword', max_length=64),
|
||||
user: dict = Depends(permission_guard),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -117,6 +121,7 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None:
|
||||
user_info=user,
|
||||
region_id=region_id,
|
||||
business_id=business_id,
|
||||
user_keyword=user_keyword,
|
||||
)
|
||||
)
|
||||
except (PermissionError, ValueError) as exc:
|
||||
@@ -144,7 +149,7 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None:
|
||||
|
||||
@router.post(prefix)
|
||||
async def create_order(
|
||||
body: MarketPresignOrderBody,
|
||||
body: MarketPresignOrderCreateBody,
|
||||
user: dict = Depends(permission_guard),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -442,6 +447,51 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None:
|
||||
except (PermissionError, ValueError) as exc:
|
||||
return error_response(400, str(exc))
|
||||
|
||||
@router.post(f'{prefix}/{{protocol_id}}/audit')
|
||||
async def audit_protocol(
|
||||
protocol_id: str,
|
||||
body: MarketProtocolAuditBody,
|
||||
source: Literal['cell_storage', 'research_donation'] = Query(...),
|
||||
user: dict = Depends(permission_guard),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
data = market_protocol.audit_protocol(
|
||||
db,
|
||||
source=source,
|
||||
protocol_id=protocol_id,
|
||||
audit_status=body.audit_status,
|
||||
audit_remark=body.audit_remark,
|
||||
ctx=_context(user, scope_kind),
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(data, message='审核已提交')
|
||||
except PermissionError as exc:
|
||||
return error_response(403, str(exc))
|
||||
except ValueError as exc:
|
||||
return error_response(400, str(exc))
|
||||
|
||||
@router.delete(f'{prefix}/{{protocol_id}}')
|
||||
async def delete_protocol(
|
||||
protocol_id: str,
|
||||
source: Literal['cell_storage', 'research_donation'] = Query(...),
|
||||
user: dict = Depends(permission_guard),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
market_protocol.delete_protocol(
|
||||
db,
|
||||
source=source,
|
||||
protocol_id=protocol_id,
|
||||
ctx=_context(user, scope_kind),
|
||||
user_info=user,
|
||||
)
|
||||
return success_response(message='删除成功')
|
||||
except PermissionError as exc:
|
||||
return error_response(403, str(exc))
|
||||
except ValueError as exc:
|
||||
return error_response(400, str(exc))
|
||||
|
||||
|
||||
_register_protocol_routes('specialist')
|
||||
_register_protocol_routes('manager')
|
||||
@@ -682,6 +732,7 @@ def _register_sample_routes(scope_kind: ScopeKind) -> None:
|
||||
@router.get(f'{prefix}/form-options')
|
||||
async def sample_form_options(
|
||||
sample_id: str = Query(..., alias='sampleId'),
|
||||
action: Optional[Literal['storage-cert', 'insurance']] = None,
|
||||
user: dict = Depends(permission_guard),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -690,6 +741,7 @@ def _register_sample_routes(scope_kind: ScopeKind) -> None:
|
||||
market_sample.get_sample_form_options(
|
||||
db,
|
||||
sample_id=sample_id,
|
||||
action=action,
|
||||
ctx=_context(user, scope_kind),
|
||||
user_info=user,
|
||||
)
|
||||
@@ -973,6 +1025,7 @@ def _register_attachment_routes(scope_kind: ScopeKind) -> None:
|
||||
f'mini:market:{scope_kind}:{module}'
|
||||
)
|
||||
path = f'/mini/market/{scope_kind}/{module}/attachments'
|
||||
download_path = f'{path}/download-url'
|
||||
|
||||
def build_upload_endpoint(bound_module, bound_guard):
|
||||
async def upload_market_attachment(
|
||||
@@ -1013,5 +1066,36 @@ def _register_attachment_routes(scope_kind: ScopeKind) -> None:
|
||||
name=f'upload_market_{scope_kind}_{module}_attachment',
|
||||
)
|
||||
|
||||
def build_download_endpoint(bound_module, bound_guard):
|
||||
async def download_market_attachment(
|
||||
body: MarketAttachmentDownloadBody,
|
||||
user: dict = Depends(bound_guard),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return success_response(
|
||||
market_support.get_market_attachment_download_url(
|
||||
db,
|
||||
file_url=body.file_url,
|
||||
file_name=body.file_name,
|
||||
module=bound_module,
|
||||
ctx=_context(user, scope_kind),
|
||||
user_info=user,
|
||||
)
|
||||
)
|
||||
except (PermissionError, ValueError):
|
||||
return error_response(403, '附件不存在或无权下载')
|
||||
except Exception:
|
||||
return error_response(500, '获取下载链接失败')
|
||||
|
||||
return download_market_attachment
|
||||
|
||||
router.add_api_route(
|
||||
download_path,
|
||||
build_download_endpoint(module, permission_guard),
|
||||
methods=['POST'],
|
||||
name=f'download_market_{scope_kind}_{module}_attachment',
|
||||
)
|
||||
|
||||
_register_attachment_routes('specialist')
|
||||
_register_attachment_routes('manager')
|
||||
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_current_user_by_token
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.response import error_response, success_response
|
||||
from app.models.models import SysMenu, SysRoleMenu
|
||||
@@ -24,14 +23,13 @@ async def _current_user(request: Request, db: Session) -> dict:
|
||||
return await get_current_user_by_token(token=token, db=db)
|
||||
|
||||
|
||||
def _find_mobile_root(db: Session) -> SysMenu | None:
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
def _find_mobile_root(db: Session, tenant_no: str) -> SysMenu | None:
|
||||
rows = (
|
||||
db.query(SysMenu)
|
||||
.filter(
|
||||
SysMenu.del_flag == 0,
|
||||
SysMenu.parent_id == "0",
|
||||
SysMenu.tenant_no == tenant,
|
||||
SysMenu.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
@@ -42,13 +40,14 @@ def _find_mobile_root(db: Session) -> SysMenu | None:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_descendants(db: Session, root_id: str) -> List[SysMenu]:
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
def _collect_descendants(
|
||||
db: Session, root_id: str, tenant_no: str
|
||||
) -> List[SysMenu]:
|
||||
all_menus = (
|
||||
db.query(SysMenu)
|
||||
.filter(
|
||||
SysMenu.del_flag == 0,
|
||||
SysMenu.tenant_no == tenant,
|
||||
SysMenu.tenant_no == tenant_no,
|
||||
SysMenu.status == "1",
|
||||
SysMenu.visible == "1",
|
||||
)
|
||||
@@ -71,16 +70,15 @@ def _collect_descendants(db: Session, root_id: str) -> List[SysMenu]:
|
||||
return out
|
||||
|
||||
|
||||
def _user_menu_ids(db: Session, user_id: str) -> Set[str]:
|
||||
role_ids = get_effective_role_ids(db, user_id)
|
||||
def _user_menu_ids(db: Session, user_id: str, tenant_no: str) -> Set[str]:
|
||||
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no)
|
||||
if not role_ids:
|
||||
return set()
|
||||
tenant = settings.DEFAULT_TENANT_NO
|
||||
rows = (
|
||||
db.query(SysRoleMenu.menu_id)
|
||||
.filter(
|
||||
SysRoleMenu.del_flag == 0,
|
||||
SysRoleMenu.tenant_no == tenant,
|
||||
SysRoleMenu.tenant_no == tenant_no,
|
||||
SysRoleMenu.role_id.in_(role_ids),
|
||||
)
|
||||
.all()
|
||||
@@ -91,6 +89,20 @@ def _user_menu_ids(db: Session, user_id: str) -> Set[str]:
|
||||
def _mobile_menu_icon(path: str) -> str:
|
||||
"""按路由匹配移动端宫格图标(与 mobile-h5 van-icon 字形名对齐)。"""
|
||||
p = (path or "").lower()
|
||||
# 兼容有无前导斜杠、query 分隔写法
|
||||
if "market/webview" in p and "entry=" in p:
|
||||
# payment-chase 须先于 payment 匹配
|
||||
if "payment-chase" in p:
|
||||
return "market-payment-chase"
|
||||
if "presign" in p:
|
||||
return "market-presign"
|
||||
if "protocol" in p:
|
||||
return "market-protocol"
|
||||
if "payment" in p:
|
||||
return "market-payment"
|
||||
if "freight" in p:
|
||||
return "market-freight"
|
||||
return ""
|
||||
if "oa/start" in p:
|
||||
return "approve"
|
||||
if "oa/drafts" in p:
|
||||
@@ -115,7 +127,7 @@ def _mobile_menu_icon(path: str) -> str:
|
||||
def _mobile_menu_icon_colors(path: str) -> dict:
|
||||
"""宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。"""
|
||||
p = (path or "").lower()
|
||||
# OA 菜单 PNG 自带颜色,宫格不套色底
|
||||
# OA / 市场菜单 PNG 自带颜色,宫格不套色底
|
||||
if any(
|
||||
x in p
|
||||
for x in (
|
||||
@@ -128,13 +140,61 @@ def _mobile_menu_icon_colors(path: str) -> dict:
|
||||
"oa/mentions",
|
||||
"oa/wechat-notify",
|
||||
)
|
||||
):
|
||||
) or ("market/webview" in p and "entry=" in p):
|
||||
return {"iconBg": "transparent", "iconFg": ""}
|
||||
if "form-sink" in p:
|
||||
return {"iconBg": "#eef2f1", "iconFg": "#3d524a"}
|
||||
return {"iconBg": "#eef2f1", "iconFg": "#3d524a"}
|
||||
|
||||
|
||||
def list_mobile_menu_items(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
tenant_no: str,
|
||||
is_super_admin: bool = False,
|
||||
) -> List[dict]:
|
||||
"""列出当前租户下已授权的移动端叶子入口。"""
|
||||
tenant = (tenant_no or "").strip()
|
||||
uid = (user_id or "").strip()
|
||||
if not tenant or not uid:
|
||||
return []
|
||||
|
||||
root = _find_mobile_root(db, tenant)
|
||||
if not root:
|
||||
return []
|
||||
|
||||
descendants = _collect_descendants(db, root.id, tenant)
|
||||
by_id = {menu.id: menu for menu in descendants}
|
||||
allowed_ids = None if is_super_admin else _user_menu_ids(db, uid, tenant)
|
||||
items: List[dict] = []
|
||||
for menu in descendants:
|
||||
if (menu.menu_type or "").upper() == "F":
|
||||
continue
|
||||
path = (menu.path or "").strip()
|
||||
if not path:
|
||||
continue
|
||||
if "form-sink" in path.lower() or "水槽" in str(menu.menu_name or ""):
|
||||
continue
|
||||
if allowed_ids is not None and menu.id not in allowed_ids:
|
||||
continue
|
||||
colors = _mobile_menu_icon_colors(path)
|
||||
parent = by_id.get((menu.parent_id or "").strip())
|
||||
item = {
|
||||
"id": menu.id,
|
||||
"title": menu.menu_name,
|
||||
"path": path if path.startswith("/") else f"/{path}",
|
||||
"icon": _mobile_menu_icon(path),
|
||||
"iconBg": colors["iconBg"],
|
||||
"iconFg": colors["iconFg"],
|
||||
"perms": (menu.perms or "").strip() or None,
|
||||
}
|
||||
if parent is not None and parent.id != root.id:
|
||||
item["groupTitle"] = parent.menu_name
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/menus")
|
||||
async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
@@ -143,38 +203,14 @@ async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)):
|
||||
return error_response(401, "未授权")
|
||||
|
||||
user_id = str(user.get("user_id") or user.get("sub") or "").strip()
|
||||
if not user_id:
|
||||
tenant_no = str(user.get("tenant_no") or "").strip()
|
||||
if not user_id or not tenant_no:
|
||||
return error_response(401, "未授权")
|
||||
|
||||
root = _find_mobile_root(db)
|
||||
if not root:
|
||||
return success_response({"list": []})
|
||||
|
||||
descendants = _collect_descendants(db, root.id)
|
||||
web_role = resolve_web_role_for_user(db, user_id)
|
||||
allowed_ids = None if web_role == "super_admin" else _user_menu_ids(db, user_id)
|
||||
|
||||
items = []
|
||||
for m in descendants:
|
||||
if (m.menu_type or "").upper() == "F":
|
||||
continue
|
||||
path = (m.path or "").strip()
|
||||
if not path:
|
||||
continue
|
||||
if "form-sink" in path.lower() or "水槽" in str(m.menu_name or ""):
|
||||
continue
|
||||
if allowed_ids is not None and m.id not in allowed_ids:
|
||||
continue
|
||||
colors = _mobile_menu_icon_colors(path)
|
||||
items.append(
|
||||
{
|
||||
"id": m.id,
|
||||
"title": m.menu_name,
|
||||
"path": path if path.startswith("/") else f"/{path}",
|
||||
"icon": _mobile_menu_icon(path),
|
||||
"iconBg": colors["iconBg"],
|
||||
"iconFg": colors["iconFg"],
|
||||
"perms": (m.perms or "").strip() or None,
|
||||
}
|
||||
)
|
||||
web_role = resolve_web_role_for_user(db, user_id, tenant_no=tenant_no)
|
||||
items = list_mobile_menu_items(
|
||||
db,
|
||||
user_id=user_id,
|
||||
tenant_no=tenant_no,
|
||||
is_super_admin=web_role == "super_admin",
|
||||
)
|
||||
return success_response({"list": items})
|
||||
@@ -124,6 +124,8 @@ def create_customer(
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> BizCustomer:
|
||||
name = (customer_name or "").strip()
|
||||
if not name:
|
||||
@@ -147,15 +149,18 @@ def create_customer(
|
||||
order_num=order_num if order_num is not None else 0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_dept_id=create_dept_id,
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
else:
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
@@ -219,14 +224,23 @@ def update_customer(
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_customer(db: Session, customer_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
def soft_delete_customer(
|
||||
db: Session,
|
||||
customer_id: str,
|
||||
tenant_no: Optional[str] = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
obj = get_customer(db, customer_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("客户不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCollectionProtocol, BizProtocolSample
|
||||
from app.models.models import BizCollectionProtocol, BizPresignOrder, BizProtocolSample
|
||||
from app.models.research_donation import (
|
||||
BizProtocolDonationConvert,
|
||||
BizResearchDonation,
|
||||
@@ -193,10 +193,11 @@ def assert_biz_no_available(
|
||||
biz_no: str,
|
||||
exclude_protocol_id: Optional[str] = None,
|
||||
exclude_donation_id: Optional[str] = None,
|
||||
exclude_presign_order_id: Optional[str] = None,
|
||||
for_donation: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
校验业务编号在采集协议与科研捐赠间可用。
|
||||
校验业务编号在采集协议、科研捐赠与预签约订单间可用。
|
||||
若当前单存在转移配对,则豁免配对单据上的编号占用。
|
||||
"""
|
||||
pn = (biz_no or "").strip()
|
||||
@@ -259,6 +260,22 @@ def assert_biz_no_available(
|
||||
raise ValueError("捐赠协议号已存在")
|
||||
raise ValueError("协议编号已被捐赠协议单使用")
|
||||
|
||||
presign_rows = (
|
||||
db.query(BizPresignOrder)
|
||||
.filter(
|
||||
and_(
|
||||
BizPresignOrder.protocol_no == pn,
|
||||
BizPresignOrder.tenant_no == tenant_no,
|
||||
BizPresignOrder.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for row in presign_rows:
|
||||
if exclude_presign_order_id and row.id == exclude_presign_order_id:
|
||||
continue
|
||||
raise ValueError("协议编号已存在")
|
||||
|
||||
|
||||
def upsert_convert_link_for_to_storage(
|
||||
db: Session,
|
||||
|
||||
@@ -48,6 +48,18 @@ def _fmt_dt(t: Optional[datetime]) -> str:
|
||||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||||
|
||||
|
||||
def _sort_pending_list_items(items: List[Dict[str, Any]]) -> None:
|
||||
"""待接收:etaTime 升序(空值最后),同 ETA 时 sampleCode 倒序。"""
|
||||
items.sort(key=lambda x: (x.get("sampleCode") or ""), reverse=True)
|
||||
items.sort(key=lambda x: (not x.get("etaTime"), x.get("etaTime") or ""))
|
||||
|
||||
|
||||
def _sort_received_list_items(items: List[Dict[str, Any]]) -> None:
|
||||
"""已接收:receptionTime 倒序,同接收时间时 sampleCode 倒序。"""
|
||||
items.sort(key=lambda x: (x.get("sampleCode") or ""), reverse=True)
|
||||
items.sort(key=lambda x: (x.get("receptionTime") or ""), reverse=True)
|
||||
|
||||
|
||||
def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str:
|
||||
val = (value or "").strip()
|
||||
if len(val) == 10:
|
||||
@@ -501,6 +513,7 @@ def list_pending_protocol_samples(
|
||||
BizProtocolSampleTransport.eta_time.is_(None),
|
||||
BizProtocolSampleTransport.eta_time.asc(),
|
||||
BizProtocolSample.create_time.asc(),
|
||||
BizProtocolSample.sample_code.desc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
@@ -516,13 +529,14 @@ def list_pending_protocol_samples(
|
||||
BizResearchDonationSampleTransport.eta_time.is_(None),
|
||||
BizResearchDonationSampleTransport.eta_time.asc(),
|
||||
BizResearchDonationSample.create_time.asc(),
|
||||
BizResearchDonationSample.sample_code.desc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
all_items.extend(_donation_rows_to_sample_list_items(db, donation_rows, tenant_no))
|
||||
|
||||
# 统一按 etaTime 排序(空值排后)
|
||||
all_items.sort(key=lambda x: (not x.get("etaTime"), x.get("etaTime") or ""))
|
||||
# 统一按 etaTime 升序(空值排后),同 ETA 时 sampleCode 倒序
|
||||
_sort_pending_list_items(all_items)
|
||||
|
||||
start_eta = _normalize_date_bound(eta_time_start) if eta_time_start else ""
|
||||
end_eta = _normalize_date_bound(eta_time_end, end_of_day=True) if eta_time_end else ""
|
||||
@@ -628,7 +642,10 @@ def list_received_protocol_samples(
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
receptions = (
|
||||
q_rec.order_by(BizSampleReception.reception_time.desc())
|
||||
q_rec.order_by(
|
||||
BizSampleReception.reception_time.desc(),
|
||||
BizSampleReception.sample_code.desc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
@@ -681,8 +698,8 @@ def list_received_protocol_samples(
|
||||
donation_ordered = [donation_row_by_sample[sid] for sid in donation_sample_ids if sid in donation_row_by_sample]
|
||||
items.extend(_donation_rows_to_sample_list_items(db, donation_ordered, tenant_no, reception_map=donation_reception_map))
|
||||
|
||||
# 按 reception_time DESC 排序
|
||||
items.sort(key=lambda x: x.get("receptionTime") or "", reverse=True)
|
||||
# 按 receptionTime 倒序,同时间时 sampleCode 倒序
|
||||
_sort_received_list_items(items)
|
||||
return items, int(total)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ MarketContractAmount = condecimal(
|
||||
MarketSampleTypeId = constr(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class MarketAttachmentDownloadBody(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
|
||||
file_url: str = Field(..., min_length=1, max_length=1000, alias='fileUrl')
|
||||
file_name: Optional[str] = Field(None, max_length=200, alias='fileName')
|
||||
|
||||
|
||||
class MarketPresignTransferBody(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
|
||||
@@ -95,9 +102,6 @@ class MarketPresignOrderBody(BaseModel):
|
||||
collector_user_id: Optional[str] = Field(
|
||||
None, max_length=64, alias="collectorUserId"
|
||||
)
|
||||
sales_user_id: Optional[str] = Field(
|
||||
None, max_length=64, alias="salesUserId"
|
||||
)
|
||||
region_manager_user_id: Optional[str] = Field(
|
||||
None, max_length=64, alias="regionManagerUserId"
|
||||
)
|
||||
@@ -106,7 +110,7 @@ class MarketPresignOrderBody(BaseModel):
|
||||
] = Field(None, alias='orderStatus')
|
||||
remark: Optional[str] = Field(None, max_length=512)
|
||||
sample_type_ids: Optional[List[MarketSampleTypeId]] = Field(
|
||||
None, max_length=100, alias="sampleTypeIds"
|
||||
None, min_length=1, max_length=100, alias="sampleTypeIds"
|
||||
)
|
||||
transfers: Optional[List[MarketPresignTransferBody]] = None
|
||||
|
||||
@@ -124,6 +128,16 @@ class MarketPresignOrderBody(BaseModel):
|
||||
raise ValueError('日期必须是有效的 YYYYMMDD') from exc
|
||||
return text
|
||||
|
||||
@field_validator('mother_name')
|
||||
@classmethod
|
||||
def validate_mother_name(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
raise ValueError('母亲姓名不能为空')
|
||||
return text
|
||||
|
||||
@field_validator('sample_collect_time')
|
||||
@classmethod
|
||||
def validate_sample_collect_time(
|
||||
@@ -139,6 +153,16 @@ class MarketPresignOrderBody(BaseModel):
|
||||
return text
|
||||
|
||||
|
||||
class MarketPresignOrderCreateBody(MarketPresignOrderBody):
|
||||
"""新增预签单必填字段;销售专员始终由服务端身份决定。"""
|
||||
|
||||
mother_name: str = Field(min_length=1, max_length=64, alias='motherName')
|
||||
sample_type_ids: List[MarketSampleTypeId] = Field(
|
||||
min_length=1, max_length=100, alias='sampleTypeIds'
|
||||
)
|
||||
|
||||
|
||||
|
||||
class MarketProtocolCustomerBody(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
|
||||
@@ -200,6 +224,7 @@ class MarketProtocolSampleBody(BaseModel):
|
||||
is_special: Optional[int] = Field(None, alias='isSpecial')
|
||||
special_remark: Optional[str] = Field(None, alias='specialRemark')
|
||||
remark: Optional[str] = None
|
||||
eta_time: Optional[str] = Field(None, alias='etaTime')
|
||||
|
||||
|
||||
class MarketProtocolAttachmentsBody(BaseModel):
|
||||
@@ -243,6 +268,13 @@ class MarketProtocolBody(BaseModel):
|
||||
attachments: Optional[MarketProtocolAttachmentsBody] = None
|
||||
|
||||
|
||||
class MarketProtocolAuditBody(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid', populate_by_name=True)
|
||||
|
||||
audit_status: Literal['shtg', 'shth'] = Field(..., alias='auditStatus')
|
||||
audit_remark: Optional[str] = Field(None, max_length=512, alias='auditRemark')
|
||||
|
||||
|
||||
class MarketPaymentTransferBody(BaseModel):
|
||||
"""市场侧仅可登记/编辑业务转账草稿,不接收状态及财务字段。"""
|
||||
|
||||
|
||||
@@ -299,7 +299,26 @@ def _label_map(db: Session, tenant_no: str, values: List[str]) -> Dict[str, str]
|
||||
return {value: label or value for value, label in rows}
|
||||
|
||||
|
||||
def _batch_dict(obj: BizSampleFreight, count: int, label: str) -> Dict[str, Any]:
|
||||
def _creator_name_map(
|
||||
db: Session, tenant_no: str, user_ids: List[str]
|
||||
) -> Dict[str, str]:
|
||||
wanted = list({str(user_id or '').strip() for user_id in user_ids if user_id})
|
||||
if not wanted:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(SysUser.id, SysUser.nick_name)
|
||||
.filter(
|
||||
SysUser.id.in_(wanted),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {user_id: nick_name or '' for user_id, nick_name in rows}
|
||||
|
||||
|
||||
def _batch_dict(
|
||||
obj: BizSampleFreight, count: int, label: str, creator_name: str = ''
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': obj.id,
|
||||
'freightNo': obj.freight_no or '',
|
||||
@@ -308,6 +327,8 @@ def _batch_dict(obj: BizSampleFreight, count: int, label: str) -> Dict[str, Any]
|
||||
'freightAmount': str((obj.freight_amount or Decimal('0')).quantize(Decimal('0.01'))),
|
||||
'visibleItemCount': int(count),
|
||||
'remark': obj.remark or '',
|
||||
'createBy': obj.create_by or '',
|
||||
'createByName': creator_name,
|
||||
'createTime': _fmt_dt(obj.create_time),
|
||||
'updateTime': _fmt_dt(obj.update_time),
|
||||
}
|
||||
@@ -342,14 +363,39 @@ def list_freights(
|
||||
batches = q.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
ids = [row.id for row in batches]
|
||||
visible_rows = _visible_rows(db, ids, ctx, user_info)
|
||||
active_counts = {
|
||||
freight_id: int(count)
|
||||
for freight_id, count in db.query(
|
||||
BizSampleFreightItem.freight_id,
|
||||
func.count(BizSampleFreightItem.id),
|
||||
).filter(
|
||||
BizSampleFreightItem.freight_id.in_(ids or ['']),
|
||||
BizSampleFreightItem.tenant_no == ctx.tenant_no,
|
||||
BizSampleFreightItem.del_flag == 0,
|
||||
).group_by(BizSampleFreightItem.freight_id).all()
|
||||
}
|
||||
by_batch: Dict[str, list] = {}
|
||||
for row in visible_rows:
|
||||
by_batch.setdefault(row[0].freight_id, []).append(row)
|
||||
labels = _label_map(db, ctx.tenant_no, [row.transport_mode for row in batches])
|
||||
creator_names = _creator_name_map(
|
||||
db, ctx.tenant_no, [row.create_by for row in batches]
|
||||
)
|
||||
result = []
|
||||
for batch in batches:
|
||||
rows = by_batch.get(batch.id, [])
|
||||
data = _batch_dict(batch, len(rows), labels.get(batch.transport_mode or '', ''))
|
||||
data = _batch_dict(
|
||||
batch,
|
||||
len(rows),
|
||||
labels.get(batch.transport_mode or '', ''),
|
||||
creator_names.get(batch.create_by or '', ''),
|
||||
)
|
||||
has_outside_scope_item = active_counts.get(batch.id, 0) > len(rows)
|
||||
data['allowedActions'] = (
|
||||
['link', 'unlink']
|
||||
if has_outside_scope_item
|
||||
else ['edit', 'link', 'unlink']
|
||||
)
|
||||
if include_items:
|
||||
data['items'] = [_item_dict(row) for row in rows]
|
||||
result.append(data)
|
||||
@@ -367,7 +413,13 @@ def get_freight_detail(db: Session, freight_id: str, *, ctx: MarketFieldContext,
|
||||
if not obj or not rows:
|
||||
raise HTTPException(status_code=404, detail='数据不存在或无权访问')
|
||||
labels = _label_map(db, ctx.tenant_no, [obj.transport_mode])
|
||||
data = _batch_dict(obj, len(rows), labels.get(obj.transport_mode or '', ''))
|
||||
creator_names = _creator_name_map(db, ctx.tenant_no, [obj.create_by])
|
||||
data = _batch_dict(
|
||||
obj,
|
||||
len(rows),
|
||||
labels.get(obj.transport_mode or '', ''),
|
||||
creator_names.get(obj.create_by or '', ''),
|
||||
)
|
||||
data['items'] = [_item_dict(row) for row in rows]
|
||||
visible_item_ids = {row[0].id for row in rows}
|
||||
has_outside_scope_item = (
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.models import (
|
||||
BizCollectionRegion,
|
||||
BizCollectionRegionStaff,
|
||||
BizPresignOrder,
|
||||
BizPresignOrderSampleType,
|
||||
BizPresignTransfer,
|
||||
)
|
||||
from app.schemas.market_mobile import (
|
||||
@@ -47,6 +48,76 @@ def _mobile_transfer_to_dict(
|
||||
return data
|
||||
|
||||
|
||||
def _reference_data(
|
||||
db: Session, rows: List[BizPresignOrder], tenant_no: str
|
||||
) -> dict:
|
||||
order_ids = [row.id for row in rows]
|
||||
region_ids = {row.region_id for row in rows if row.region_id}
|
||||
hospital_ids = {row.hospital_id for row in rows if row.hospital_id}
|
||||
region_names = {}
|
||||
if region_ids:
|
||||
region_names = {
|
||||
str(row.id): str(row.region_name or '').strip()
|
||||
for row in db.query(BizCollectionRegion).filter(
|
||||
BizCollectionRegion.id.in_(region_ids),
|
||||
BizCollectionRegion.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
hospital_names = {}
|
||||
if hospital_ids:
|
||||
hospital_names = {
|
||||
str(row.id): str(row.hospital_name or '').strip()
|
||||
for row in db.query(BizCollectionHospital).filter(
|
||||
BizCollectionHospital.id.in_(hospital_ids),
|
||||
BizCollectionHospital.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
type_ids_by_order = {order_id: [] for order_id in order_ids}
|
||||
if order_ids:
|
||||
links = (
|
||||
db.query(BizPresignOrderSampleType)
|
||||
.filter(
|
||||
BizPresignOrderSampleType.presign_order_id.in_(order_ids),
|
||||
BizPresignOrderSampleType.tenant_no == tenant_no,
|
||||
BizPresignOrderSampleType.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizPresignOrderSampleType.order_num.asc(),
|
||||
BizPresignOrderSampleType.create_time.asc(),
|
||||
BizPresignOrderSampleType.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
if link.sample_type_id:
|
||||
type_ids_by_order.setdefault(link.presign_order_id, []).append(
|
||||
str(link.sample_type_id)
|
||||
)
|
||||
type_ids = {
|
||||
type_id
|
||||
for order_type_ids in type_ids_by_order.values()
|
||||
for type_id in order_type_ids
|
||||
}
|
||||
type_names = {}
|
||||
if type_ids:
|
||||
type_names = {
|
||||
str(row.id): str(row.type_name or '').strip()
|
||||
for row in db.query(BizCellSampleType).filter(
|
||||
BizCellSampleType.id.in_(type_ids),
|
||||
BizCellSampleType.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
return {
|
||||
'regionNames': region_names,
|
||||
'hospitalNames': hospital_names,
|
||||
'sampleTypeIds': type_ids_by_order,
|
||||
'sampleTypeNames': {
|
||||
order_id: [type_names.get(type_id, '') for type_id in order_type_ids]
|
||||
for order_id, order_type_ids in type_ids_by_order.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _mobile_order_to_dict(
|
||||
db: Session,
|
||||
row: BizPresignOrder,
|
||||
@@ -55,6 +126,7 @@ def _mobile_order_to_dict(
|
||||
with_children: bool,
|
||||
deposit_amount: Optional[Decimal] = None,
|
||||
user_name_map: Optional[dict] = None,
|
||||
reference_data: Optional[dict] = None,
|
||||
) -> dict:
|
||||
deposit = (
|
||||
deposit_amount
|
||||
@@ -73,10 +145,14 @@ def _mobile_order_to_dict(
|
||||
)
|
||||
data['contractAmount'] = _money_text(row.contract_amount)
|
||||
data['confirmedDepositAmount'] = _money_text(deposit)
|
||||
references = reference_data or _reference_data(db, [row], tenant_no)
|
||||
data['regionName'] = references['regionNames'].get(row.region_id or '', '')
|
||||
data['hospitalName'] = references['hospitalNames'].get(
|
||||
row.hospital_id or '', ''
|
||||
)
|
||||
data['sampleTypeIds'] = references['sampleTypeIds'].get(row.id, [])
|
||||
data['sampleTypeNames'] = references['sampleTypeNames'].get(row.id, [])
|
||||
if with_children:
|
||||
data['sampleTypeIds'] = presign_crud.list_sample_type_ids(
|
||||
db, presign_order_id=row.id, tenant_no=tenant_no
|
||||
)
|
||||
transfer_rows = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
@@ -151,8 +227,6 @@ def _manager_assignment_allowed(
|
||||
region_id: Optional[str],
|
||||
region_manager_user_id: Optional[str],
|
||||
) -> bool:
|
||||
if (region_manager_user_id or "").strip() == ctx.user_id:
|
||||
return True
|
||||
region = (region_id or "").strip()
|
||||
if not region:
|
||||
return False
|
||||
@@ -300,6 +374,7 @@ def list_presign_orders(
|
||||
user_name_map = presign_crud._user_nick_name_map(
|
||||
db, person_ids, ctx.tenant_no
|
||||
)
|
||||
references = _reference_data(db, rows, ctx.tenant_no)
|
||||
return [
|
||||
_mobile_order_to_dict(
|
||||
db,
|
||||
@@ -308,6 +383,7 @@ def list_presign_orders(
|
||||
with_children=False,
|
||||
deposit_amount=deposit_map.get(row.id, 0),
|
||||
user_name_map=user_name_map,
|
||||
reference_data=references,
|
||||
)
|
||||
for row in rows
|
||||
], int(total)
|
||||
@@ -323,8 +399,13 @@ def get_presign_order(
|
||||
row = assert_presign_access(
|
||||
db, order_id, ctx, user_info=user_info
|
||||
)
|
||||
references = _reference_data(db, [row], ctx.tenant_no)
|
||||
return _mobile_order_to_dict(
|
||||
db, row, ctx.tenant_no, with_children=True
|
||||
db,
|
||||
row,
|
||||
ctx.tenant_no,
|
||||
with_children=True,
|
||||
reference_data=references,
|
||||
)
|
||||
|
||||
|
||||
@@ -446,7 +527,7 @@ def update_presign_order(
|
||||
)
|
||||
if ctx.scope_kind == "specialist":
|
||||
fields["sales_user_id"] = ctx.user_id
|
||||
else:
|
||||
elif str(resulting_region_id or '').strip() != str(current.region_id or '').strip():
|
||||
_ensure_manager_assignment(
|
||||
db,
|
||||
ctx,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, literal, or_
|
||||
@@ -51,6 +51,15 @@ def _fmt_dt(value: Optional[datetime]) -> str:
|
||||
return value.strftime('%Y-%m-%d %H:%M:%S') if value else ''
|
||||
|
||||
|
||||
def _fmt_date(value: Any) -> str:
|
||||
if not value:
|
||||
return ''
|
||||
if hasattr(value, 'strftime'):
|
||||
return value.strftime('%Y-%m-%d')
|
||||
text = str(value)
|
||||
return f'{text[:4]}-{text[4:6]}-{text[6:8]}' if len(text) == 8 and text.isdigit() else text
|
||||
|
||||
|
||||
def _parse_dt(value: Any) -> Optional[datetime]:
|
||||
if value in (None, ''):
|
||||
return None
|
||||
@@ -325,11 +334,21 @@ def _raw_item(source: str, sample, parent, collection, party) -> Dict[str, Any]:
|
||||
'sampleRowId': sample.id,
|
||||
'parentId': parent.id,
|
||||
'protocolNo': number or '',
|
||||
'protocolStatus': (
|
||||
parent.protocol_status
|
||||
if source == CELL_SOURCE
|
||||
else parent.donation_status
|
||||
) or '',
|
||||
'sampleCode': sample.sample_code or '',
|
||||
'sampleTypeId': sample.sample_type_id or '',
|
||||
'sampleTypeName': '',
|
||||
'sampleStatus': sample.sample_status or '',
|
||||
'customerName': party.mother_name if party else '',
|
||||
'motherName': party.mother_name if party else '',
|
||||
'newbornName': party.newborn_name if party else '',
|
||||
'expectedDeliveryDate': _fmt_date(
|
||||
party.expected_delivery_date if party else None
|
||||
),
|
||||
'regionId': collection.region_id or '',
|
||||
'hospitalId': collection.hospital_id or '',
|
||||
'salesUserId': collection.sales_user_id or '',
|
||||
@@ -478,12 +497,15 @@ def _handle_maps(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> Tu
|
||||
return cert_map, todo_map
|
||||
|
||||
|
||||
def _handle_summary(row, *, insurance: bool = False) -> Dict[str, Any]:
|
||||
def _handle_summary(
|
||||
row, *, insurance: bool = False, handler_user_name: str = ''
|
||||
) -> Dict[str, Any]:
|
||||
if row is None:
|
||||
return {
|
||||
**_status_meta('pending', '未办理'),
|
||||
'handleTime': '',
|
||||
'handlerUserId': '',
|
||||
'handlerUserName': '',
|
||||
'attachments': '',
|
||||
'remark': '',
|
||||
}
|
||||
@@ -494,11 +516,29 @@ def _handle_summary(row, *, insurance: bool = False) -> Dict[str, Any]:
|
||||
**_status_meta('handled' if handled else 'pending', '已办理' if handled else '未办理'),
|
||||
'handleTime': _fmt_dt(row.handle_time),
|
||||
'handlerUserId': row.handler_user_id or '',
|
||||
'handlerUserName': handler_user_name,
|
||||
'attachments': attachments or '',
|
||||
'remark': remark or '',
|
||||
}
|
||||
|
||||
|
||||
def _referenced_user_name_map(
|
||||
db: Session, tenant_no: str, user_ids: List[str]
|
||||
) -> Dict[str, str]:
|
||||
wanted = {str(user_id or '').strip() for user_id in user_ids if user_id}
|
||||
if not wanted:
|
||||
return {}
|
||||
return {
|
||||
str(user_id): str(nick_name or '').strip()
|
||||
for user_id, nick_name in db.query(SysUser.id, SysUser.nick_name)
|
||||
.filter(
|
||||
SysUser.id.in_(wanted),
|
||||
SysUser.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None:
|
||||
type_ids = {item['sampleTypeId'] for item in items if item['sampleTypeId']}
|
||||
type_map = {}
|
||||
@@ -513,6 +553,15 @@ def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None:
|
||||
}
|
||||
reception_map, qc_map, storage_map = _latest_maps(db, tenant_no, items)
|
||||
cert_map, todo_map = _handle_maps(db, tenant_no, items)
|
||||
handler_names = _referenced_user_name_map(
|
||||
db,
|
||||
tenant_no,
|
||||
[
|
||||
row.handler_user_id
|
||||
for row in (*cert_map.values(), *todo_map.values())
|
||||
if row.handler_user_id
|
||||
],
|
||||
)
|
||||
for item in items:
|
||||
item['sampleTypeName'] = type_map.get(item['sampleTypeId'], '')
|
||||
sample_status = _sample_status(item['sampleStatus'])
|
||||
@@ -557,10 +606,24 @@ def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None:
|
||||
'time': _fmt_dt(storage.storage_time) if storage else '',
|
||||
},
|
||||
}
|
||||
item['labStorageCode'] = (
|
||||
getattr(reception, 'lab_storage_code', None) or ''
|
||||
)
|
||||
if item['source'] == CELL_SOURCE:
|
||||
item['storageCert'] = _handle_summary(cert_map.get(item['parentId']))
|
||||
cert = cert_map.get(item['parentId'])
|
||||
todo = todo_map.get(item['parentId'])
|
||||
item['storageCert'] = _handle_summary(
|
||||
cert,
|
||||
handler_user_name=handler_names.get(
|
||||
getattr(cert, 'handler_user_id', '') or '', ''
|
||||
),
|
||||
)
|
||||
item['insurance'] = _handle_summary(
|
||||
todo_map.get(item['parentId']), insurance=True
|
||||
todo,
|
||||
insurance=True,
|
||||
handler_user_name=handler_names.get(
|
||||
getattr(todo, 'handler_user_id', '') or '', ''
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -702,6 +765,7 @@ def get_sample_detail(
|
||||
{'label': '协议编号', 'value': item['protocolNo']},
|
||||
{'label': '样本编码', 'value': item['sampleCode']},
|
||||
{'label': '样本类型', 'value': item['sampleTypeName']},
|
||||
{'label': '实验室入库编码', 'value': item['labStorageCode']},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -757,6 +821,7 @@ def get_sample_form_options(
|
||||
db: Session,
|
||||
*,
|
||||
sample_id: str,
|
||||
action: Optional[Literal['storage-cert', 'insurance']] = None,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> Dict[str, List[Dict[str, str]]]:
|
||||
@@ -769,11 +834,44 @@ def get_sample_form_options(
|
||||
.order_by(SysUser.nick_name.asc(), SysUser.id.asc())
|
||||
.all()
|
||||
)
|
||||
referenced_ids = []
|
||||
if action:
|
||||
model = (
|
||||
BizProtocolStorageCert
|
||||
if action == 'storage-cert'
|
||||
else BizProtocolFullmoonTodo
|
||||
)
|
||||
historical = (
|
||||
db.query(model)
|
||||
.filter(
|
||||
model.collection_protocol_id == item['parentId'],
|
||||
model.tenant_no == ctx.tenant_no,
|
||||
model.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
model.update_time.desc(),
|
||||
model.create_time.desc(),
|
||||
model.id.desc(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if historical is not None and historical.handler_user_id:
|
||||
referenced_ids.append(historical.handler_user_id)
|
||||
referenced_names = _referenced_user_name_map(
|
||||
db, ctx.tenant_no, referenced_ids
|
||||
)
|
||||
staff = [
|
||||
{'value': str(user.id), 'label': str(user.nick_name or '')}
|
||||
for user in users
|
||||
]
|
||||
included = {option['value'] for option in staff}
|
||||
for user_id in referenced_ids:
|
||||
value = str(user_id)
|
||||
if value not in included and value in referenced_names:
|
||||
staff.append({'value': value, 'label': referenced_names[value]})
|
||||
included.add(value)
|
||||
return {
|
||||
'staff': [
|
||||
{'value': str(user.id), 'label': str(user.nick_name or '')}
|
||||
for user in users
|
||||
]
|
||||
'staff': staff
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,17 +20,31 @@ from app.core.storage import storage
|
||||
from app.models.models import (
|
||||
BizAttachment,
|
||||
BizCellSampleType,
|
||||
BizCollectionProtocol,
|
||||
BizCollectionHospital,
|
||||
BizProtocolCollectionInfo,
|
||||
BizCollectionRegion,
|
||||
BizCollectionRegionStaff,
|
||||
BizItemUnit,
|
||||
BizPresignOrder,
|
||||
BizPresignTransfer,
|
||||
BizProtocolFullmoonTodo,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolStorageCert,
|
||||
BizSampleCode,
|
||||
SysDictData,
|
||||
SysEthnic,
|
||||
SysUser,
|
||||
)
|
||||
from app.models.research_donation import BizResearchDonationCollection
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation,
|
||||
BizResearchDonationCollection,
|
||||
)
|
||||
from app.services.market_field_scope import (
|
||||
MarketFieldContext,
|
||||
apply_cell_protocol_scope,
|
||||
apply_donation_scope,
|
||||
apply_presign_scope,
|
||||
assert_cell_protocol_access,
|
||||
assert_donation_access,
|
||||
assert_presign_access,
|
||||
@@ -177,7 +191,8 @@ def get_form_options(
|
||||
user_info: dict,
|
||||
region_id: Optional[str] = None,
|
||||
business_id: Optional[str] = None,
|
||||
) -> Dict[str, List[dict]]:
|
||||
user_keyword: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""返回移动表单最小选项集;不会返回联系方式或证件等 PII。"""
|
||||
_verify_identity(ctx, user_info)
|
||||
normalized_module = str(module or '').strip()
|
||||
@@ -279,13 +294,95 @@ def get_form_options(
|
||||
'isLeader': bool(binding.is_leader),
|
||||
}
|
||||
)
|
||||
current_user = (
|
||||
db.query(SysUser)
|
||||
.filter(
|
||||
SysUser.id == ctx.user_id,
|
||||
SysUser.tenant_no == ctx.tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.status == '1',
|
||||
)
|
||||
.first()
|
||||
)
|
||||
searched_users = []
|
||||
if normalized_module == 'presign' and user_keyword is not None:
|
||||
keyword = str(user_keyword or '').strip()
|
||||
user_query = db.query(SysUser).filter(
|
||||
SysUser.tenant_no == ctx.tenant_no,
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.status == '1',
|
||||
)
|
||||
if keyword:
|
||||
like = f'%{keyword}%'
|
||||
user_query = user_query.filter(
|
||||
or_(SysUser.nick_name.like(like), SysUser.user_no.like(like))
|
||||
)
|
||||
searched_users = (
|
||||
user_query.order_by(SysUser.nick_name.asc(), SysUser.id.asc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
result = {
|
||||
'regions': [_option(row.id, row.region_name) for row in regions],
|
||||
'hospitals': [_option(row.id, row.hospital_name) for row in hospitals],
|
||||
'staff': staff,
|
||||
'sampleTypes': [_option(row.id, row.type_name) for row in sample_types],
|
||||
'units': [_option(row.id, row.unit_name) for row in units],
|
||||
'currentUser': (
|
||||
_option(current_user.id, current_user.nick_name)
|
||||
if current_user
|
||||
else None
|
||||
),
|
||||
}
|
||||
if normalized_module == 'protocol':
|
||||
ethnicities = (
|
||||
db.query(SysEthnic)
|
||||
.filter(
|
||||
SysEthnic.del_flag == 0,
|
||||
or_(SysEthnic.tenant_no == '', SysEthnic.tenant_no == ctx.tenant_no),
|
||||
)
|
||||
.order_by(SysEthnic.order_num.asc(), SysEthnic.ethnic_code.asc())
|
||||
.all()
|
||||
)
|
||||
# 多取候选再过滤已占用,尽量凑满可选列表
|
||||
sample_codes = (
|
||||
db.query(BizSampleCode)
|
||||
.filter(
|
||||
BizSampleCode.tenant_no == ctx.tenant_no,
|
||||
BizSampleCode.del_flag == 0,
|
||||
)
|
||||
.order_by(BizSampleCode.create_time.desc(), BizSampleCode.sample_code.desc())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
from app.crud import collection_protocol as cell_crud
|
||||
|
||||
used_codes = cell_crud.get_sample_codes_used_in_protocol_samples(
|
||||
db,
|
||||
tenant_no=ctx.tenant_no,
|
||||
sample_codes=[(row.sample_code or '').strip() for row in sample_codes],
|
||||
)
|
||||
available_codes = [
|
||||
row
|
||||
for row in sample_codes
|
||||
if (row.sample_code or '').strip()
|
||||
and (row.sample_code or '').strip() not in used_codes
|
||||
][:100]
|
||||
result.update(
|
||||
{
|
||||
'ethnicities': [
|
||||
_option(row.ethnic_name, row.ethnic_name)
|
||||
for row in ethnicities
|
||||
],
|
||||
'sampleCodes': [
|
||||
{
|
||||
**_option(row.id, row.sample_code),
|
||||
'sampleCode': row.sample_code or '',
|
||||
}
|
||||
for row in available_codes
|
||||
],
|
||||
}
|
||||
)
|
||||
if normalized_module == 'presign':
|
||||
result.update(
|
||||
{
|
||||
@@ -300,6 +397,7 @@ def get_form_options(
|
||||
{'label': '已作废', 'value': 'void'},
|
||||
],
|
||||
'transferTypes': _dict_options(db, ctx.tenant_no, 'zzfs'),
|
||||
'users': [_option(row.id, row.nick_name) for row in searched_users],
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -500,6 +598,241 @@ def _attachment_candidates(value: str) -> List[str]:
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _attachment_value_matches(value: Any, candidates: Iterable[str]) -> bool:
|
||||
expected = set(candidates)
|
||||
for item in str(value or '').split(','):
|
||||
normalized = str(item or '').strip()
|
||||
if normalized and expected.intersection(_attachment_candidates(normalized)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _attachment_columns_filter(columns, candidates: Iterable[str]):
|
||||
return or_(
|
||||
*(
|
||||
column.contains(candidate)
|
||||
for column in columns
|
||||
for candidate in candidates
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _rows_reference_attachment(rows, field_names, candidates) -> bool:
|
||||
return any(
|
||||
_attachment_value_matches(getattr(row, field_name, None), candidates)
|
||||
for row in rows
|
||||
for field_name in field_names
|
||||
)
|
||||
|
||||
|
||||
def _has_accessible_market_attachment_reference(
|
||||
db: Session,
|
||||
*,
|
||||
module: str,
|
||||
candidates: List[str],
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
) -> bool:
|
||||
"""附件必须被当前模块中调用人可访问的业务记录引用。"""
|
||||
if module == 'protocol':
|
||||
cell_fields = (
|
||||
'protocol_attachments',
|
||||
'consent_form_urls',
|
||||
'health_survey_form_urls',
|
||||
)
|
||||
cell_columns = tuple(
|
||||
getattr(BizCollectionProtocol, field) for field in cell_fields
|
||||
)
|
||||
cell_rows = (
|
||||
apply_cell_protocol_scope(
|
||||
db.query(BizCollectionProtocol),
|
||||
db,
|
||||
ctx,
|
||||
user_info=user_info,
|
||||
)
|
||||
.filter(_attachment_columns_filter(cell_columns, candidates))
|
||||
.all()
|
||||
)
|
||||
if _rows_reference_attachment(cell_rows, cell_fields, candidates):
|
||||
return True
|
||||
donation_fields = (
|
||||
'consent_form_urls',
|
||||
'attachment_urls',
|
||||
'infectious_disease_report_urls',
|
||||
)
|
||||
donation_columns = tuple(
|
||||
getattr(BizResearchDonation, field) for field in donation_fields
|
||||
)
|
||||
donation_rows = (
|
||||
apply_donation_scope(
|
||||
db.query(BizResearchDonation),
|
||||
db,
|
||||
ctx,
|
||||
user_info=user_info,
|
||||
)
|
||||
.filter(_attachment_columns_filter(donation_columns, candidates))
|
||||
.all()
|
||||
)
|
||||
return _rows_reference_attachment(
|
||||
donation_rows, donation_fields, candidates
|
||||
)
|
||||
|
||||
if module == 'presign':
|
||||
rows = (
|
||||
apply_presign_scope(
|
||||
db.query(BizPresignTransfer).join(
|
||||
BizPresignOrder,
|
||||
BizPresignOrder.id == BizPresignTransfer.presign_order_id,
|
||||
),
|
||||
db,
|
||||
ctx,
|
||||
user_info=user_info,
|
||||
)
|
||||
.filter(
|
||||
BizPresignTransfer.tenant_no == ctx.tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
_attachment_columns_filter(
|
||||
(BizPresignTransfer.transfer_attachments,), candidates
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return _rows_reference_attachment(
|
||||
rows, ('transfer_attachments',), candidates
|
||||
)
|
||||
|
||||
if module == 'payment':
|
||||
rows = (
|
||||
apply_cell_protocol_scope(
|
||||
db.query(BizProtocolPaymentTransfer).join(
|
||||
BizCollectionProtocol,
|
||||
BizCollectionProtocol.id
|
||||
== BizProtocolPaymentTransfer.collection_protocol_id,
|
||||
),
|
||||
db,
|
||||
ctx,
|
||||
user_info=user_info,
|
||||
)
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
_attachment_columns_filter(
|
||||
(BizProtocolPaymentTransfer.transfer_attachments,), candidates
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return _rows_reference_attachment(
|
||||
rows, ('transfer_attachments',), candidates
|
||||
)
|
||||
|
||||
if module == 'sample':
|
||||
sample_models = (
|
||||
(BizProtocolStorageCert, 'cert_image_urls'),
|
||||
(BizProtocolFullmoonTodo, 'insurance_attachments'),
|
||||
)
|
||||
for model, field_name in sample_models:
|
||||
rows = (
|
||||
apply_cell_protocol_scope(
|
||||
db.query(model).join(
|
||||
BizCollectionProtocol,
|
||||
BizCollectionProtocol.id == model.collection_protocol_id,
|
||||
),
|
||||
db,
|
||||
ctx,
|
||||
user_info=user_info,
|
||||
)
|
||||
.filter(
|
||||
model.tenant_no == ctx.tenant_no,
|
||||
model.del_flag == 0,
|
||||
_attachment_columns_filter(
|
||||
(getattr(model, field_name),), candidates
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if _rows_reference_attachment(rows, (field_name,), candidates):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_market_attachment_download_url(
|
||||
db: Session,
|
||||
*,
|
||||
file_url: str,
|
||||
file_name: Optional[str],
|
||||
module: str,
|
||||
ctx: MarketFieldContext,
|
||||
user_info: dict,
|
||||
storage_backend=storage,
|
||||
) -> Dict[str, str]:
|
||||
"""为市场移动端返回附件下载地址。
|
||||
|
||||
新附件按市场模块隔离;历史 Web 附件没有 business_type,
|
||||
但仍必须是当前租户已登记的 BizAttachment 记录。
|
||||
"""
|
||||
_verify_identity(ctx, user_info)
|
||||
normalized_module = str(module or '').strip()
|
||||
if normalized_module not in UPLOAD_MODULES:
|
||||
raise ValueError('不支持的附件模块')
|
||||
normalized_url = str(file_url or '').strip()
|
||||
if not normalized_url or len(normalized_url) > MAX_ATTACHMENT_REF_LENGTH:
|
||||
raise ValueError('文件地址无效')
|
||||
candidates = _attachment_candidates(normalized_url)
|
||||
rows = (
|
||||
db.query(BizAttachment)
|
||||
.filter(
|
||||
BizAttachment.tenant_no == ctx.tenant_no,
|
||||
BizAttachment.del_flag == 0,
|
||||
BizAttachment.file_url.in_(candidates),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
expected_staging = f'market:{ctx.scope_kind}:{normalized_module}:staging'
|
||||
allowed_bound = {
|
||||
f'market:specialist:{normalized_module}:bound',
|
||||
f'market:manager:{normalized_module}:bound',
|
||||
}
|
||||
row = next(
|
||||
(
|
||||
item
|
||||
for item in rows
|
||||
if (
|
||||
str(item.business_type or '').strip() == expected_staging
|
||||
and str(item.create_by or '') == ctx.user_id
|
||||
and not item.business_id
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
referenced_rows = [
|
||||
item
|
||||
for item in rows
|
||||
if str(item.business_type or '').strip() in ({''} | allowed_bound)
|
||||
]
|
||||
if referenced_rows and _has_accessible_market_attachment_reference(
|
||||
db,
|
||||
module=normalized_module,
|
||||
candidates=candidates,
|
||||
ctx=ctx,
|
||||
user_info=user_info,
|
||||
):
|
||||
row = referenced_rows[0]
|
||||
if row is None:
|
||||
raise PermissionError('附件不存在或无权下载')
|
||||
download_name = str(row.original_name or '').strip()
|
||||
if not download_name:
|
||||
download_name = str(file_name or '').strip()
|
||||
if not download_name:
|
||||
download_name = str(row.file_url or '').rsplit('/', 1)[-1] or 'download'
|
||||
return {
|
||||
'downloadUrl': storage_backend.presigned_download_url(
|
||||
str(row.file_url), download_name
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def validate_attachment_refs(
|
||||
db: Session,
|
||||
refs: Any,
|
||||
@@ -617,7 +950,11 @@ def allowed_actions(
|
||||
return ['withdraw']
|
||||
return []
|
||||
if module == 'protocol':
|
||||
return ['edit'] if state in {'', 'draft', 'dsx', 'dqr', 'sxz', 'ygq'} else []
|
||||
return (
|
||||
['audit', 'edit', 'delete']
|
||||
if state in {'', 'draft', 'dsx', 'dqr', 'sxz', 'ygq'}
|
||||
else []
|
||||
)
|
||||
if module == 'payment':
|
||||
return [] if state in PAYMENT_TERMINAL_PROTOCOL_STATUSES else ['register-transfer']
|
||||
if module == 'payment-transfer':
|
||||
@@ -662,13 +999,34 @@ def decorate_allowed_actions(
|
||||
card = data.get('card') if isinstance(data.get('card'), dict) else data
|
||||
source = str(card.get('source') or data.get('source') or '')
|
||||
sample_code = str(card.get('sampleCode') or data.get('sampleCode') or '')
|
||||
data['allowedActions'] = allowed_actions(
|
||||
candidates = allowed_actions(
|
||||
module,
|
||||
status=status,
|
||||
scope_kind=scope_kind,
|
||||
source=source,
|
||||
has_sample_code=bool(sample_code.strip()),
|
||||
)
|
||||
progress = data.get('progress') if isinstance(data.get('progress'), dict) else card.get('progress') or {}
|
||||
storage_ready = str((progress.get('storage') or {}).get('code') or '') == 'yrk'
|
||||
handles = next(
|
||||
(
|
||||
section
|
||||
for section in data.get('sections') or []
|
||||
if section.get('key') == 'market-handles'
|
||||
),
|
||||
{},
|
||||
)
|
||||
storage_cert = card.get('storageCert') or handles.get('storageCert') or {}
|
||||
insurance = card.get('insurance') or handles.get('insurance') or {}
|
||||
data['allowedActions'] = [
|
||||
action
|
||||
for action in candidates
|
||||
if storage_ready
|
||||
and (
|
||||
(action == 'storage-cert' and storage_cert.get('code') != 'handled')
|
||||
or (action == 'insurance' and insurance.get('code') != 'handled')
|
||||
)
|
||||
]
|
||||
return data
|
||||
if module == 'payment':
|
||||
parent_writable = status.lower() not in PAYMENT_TERMINAL_PROTOCOL_STATUSES
|
||||
@@ -724,7 +1082,7 @@ def decorate_allowed_actions(
|
||||
)
|
||||
transfers.append(transfer_data)
|
||||
data['transfers'] = transfers
|
||||
if module == 'freight' and detail:
|
||||
if module == 'freight' and (detail or isinstance(data.get('allowedActions'), list)):
|
||||
provided = data.get('allowedActions')
|
||||
safe_actions = {'edit', 'link', 'unlink'}
|
||||
data['allowedActions'] = (
|
||||
|
||||
@@ -225,6 +225,8 @@ def _create_role_customer(
|
||||
profile: SimpleNamespace,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> str:
|
||||
obj = customer_crud.create_customer(
|
||||
db,
|
||||
@@ -239,6 +241,8 @@ def _create_role_customer(
|
||||
phone_no=profile.phone_no or None,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
return obj.id
|
||||
|
||||
@@ -250,6 +254,7 @@ def _merge_and_repoint(
|
||||
to_customer: Any,
|
||||
tenant_no: str,
|
||||
profile: Optional[SimpleNamespace] = None,
|
||||
commit: bool = True,
|
||||
) -> str:
|
||||
source = get_customer(db, from_id, tenant_no=tenant_no)
|
||||
if source is not None:
|
||||
@@ -259,7 +264,9 @@ def _merge_and_repoint(
|
||||
apply_role_profile_overwrite(to_customer, profile)
|
||||
db.add(to_customer)
|
||||
repoint_customer_refs(db, from_id, to_customer.id, tenant_no)
|
||||
customer_crud.soft_delete_customer(db, from_id, tenant_no=tenant_no)
|
||||
customer_crud.soft_delete_customer(
|
||||
db, from_id, tenant_no=tenant_no, commit=commit
|
||||
)
|
||||
return to_customer.id
|
||||
|
||||
|
||||
@@ -269,6 +276,8 @@ def _sync_one_role(
|
||||
spec: Dict[str, Any],
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> str:
|
||||
profile = _build_role_profile(party, spec)
|
||||
if _is_empty(profile.customer_name):
|
||||
@@ -286,6 +295,7 @@ def _sync_one_role(
|
||||
to_customer=found,
|
||||
tenant_no=tenant_no,
|
||||
profile=profile,
|
||||
commit=commit,
|
||||
)
|
||||
apply_role_profile_overwrite(found, profile)
|
||||
db.add(found)
|
||||
@@ -303,13 +313,31 @@ def _sync_one_role(
|
||||
profile=profile,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
|
||||
def sync_party_customers(db: Session, party_dict: Dict[str, Any], tenant_no: str, user_id: str) -> Dict[str, Any]:
|
||||
def sync_party_customers(
|
||||
db: Session,
|
||||
party_dict: Dict[str, Any],
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
create_dept_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
out = dict(party_dict or {})
|
||||
for spec in _ROLE_SPECS:
|
||||
role_id = _sync_one_role(db, out, spec, tenant_no, user_id)
|
||||
role_id = _sync_one_role(
|
||||
db,
|
||||
out,
|
||||
spec,
|
||||
tenant_no,
|
||||
user_id,
|
||||
commit=commit,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
for key in spec["id_keys"]:
|
||||
out[key] = role_id
|
||||
return out
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -171,12 +171,26 @@ def build_protocol_payment_summary(
|
||||
}
|
||||
|
||||
|
||||
def _first_period_last_transfer_times(
|
||||
def pick_earliest_transfer_time_by_protocol(
|
||||
rows: Sequence[Tuple[Any, Any]],
|
||||
) -> Dict[str, datetime]:
|
||||
"""从 (protocol_id, transfer_time) 行中按协议取最早 transfer_time。"""
|
||||
earliest: Dict[str, datetime] = {}
|
||||
for pid, t in rows:
|
||||
if not pid or t is None:
|
||||
continue
|
||||
prev = earliest.get(pid)
|
||||
if prev is None or t < prev:
|
||||
earliest[pid] = t
|
||||
return earliest
|
||||
|
||||
|
||||
def _first_period_first_transfer_times(
|
||||
db: Session,
|
||||
protocol_ids: List[str],
|
||||
tenant_no: str,
|
||||
) -> Dict[str, str]:
|
||||
"""协议 → 第 1 期(schedule_no=1)多笔转账中 transfer_time 最晚的一笔。"""
|
||||
"""协议 → 第 1 期(schedule_no=1)多笔转账中 transfer_time 最早的一笔。"""
|
||||
ids = [x.strip() for x in protocol_ids if (x or "").strip()]
|
||||
if not ids:
|
||||
return {}
|
||||
@@ -200,14 +214,8 @@ def _first_period_last_transfer_times(
|
||||
)
|
||||
.all()
|
||||
)
|
||||
latest: Dict[str, datetime] = {}
|
||||
for pid, t in rows:
|
||||
if not pid or t is None:
|
||||
continue
|
||||
prev = latest.get(pid)
|
||||
if prev is None or t > prev:
|
||||
latest[pid] = t
|
||||
return {pid: _fmt_dt(t) for pid, t in latest.items() if _fmt_dt(t)}
|
||||
earliest = pick_earliest_transfer_time_by_protocol(rows)
|
||||
return {pid: _fmt_dt(t) for pid, t in earliest.items() if _fmt_dt(t)}
|
||||
|
||||
|
||||
def build_protocol_payment_summaries(
|
||||
@@ -241,7 +249,7 @@ def build_protocol_payment_summaries(
|
||||
.all()
|
||||
):
|
||||
details_by_payment.setdefault(d.payment_id, []).append(d)
|
||||
first_pay_map = _first_period_last_transfer_times(db, ids, tenant_no)
|
||||
first_pay_map = _first_period_first_transfer_times(db, ids, tenant_no)
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for pid in ids:
|
||||
pay = pay_by_proto.get(pid)
|
||||
|
||||
@@ -81,9 +81,9 @@ def _sample(db, key, *, source='cell_storage', sales='u1', manager='m1', region=
|
||||
return sample, parent
|
||||
|
||||
|
||||
def _freight(db, key, samples, *, tenant='tenant-a', amount='12.30', deleted=0):
|
||||
def _freight(db, key, samples, *, tenant='tenant-a', amount='12.30', deleted=0, create_by='u1'):
|
||||
now = datetime(2026, 10, 11, 8, 0)
|
||||
freight = BizSampleFreight(id=f'f-{key}', freight_no=f'YF-{key}', transport_mode='sf', freight_amount=Decimal(amount), tenant_no=tenant, create_dept_id='', create_time=now, update_time=now, del_flag=deleted)
|
||||
freight = BizSampleFreight(id=f'f-{key}', freight_no=f'YF-{key}', transport_mode='sf', freight_amount=Decimal(amount), tenant_no=tenant, create_by=create_by, create_dept_id='', create_time=now, update_time=now, del_flag=deleted)
|
||||
db.add(freight)
|
||||
for pos, (source, sample, parent) in enumerate(samples):
|
||||
db.add(BizSampleFreightItem(id=f'fi-{key}-{pos}', freight_id=freight.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=tenant, create_dept_id='', create_time=now, update_time=now, del_flag=0))
|
||||
@@ -106,10 +106,54 @@ def test_specialist_sees_shared_batch_but_only_own_items_and_count():
|
||||
assert total == 1 and rows[0]['visibleItemCount'] == 1
|
||||
assert [item['sampleCode'] for item in rows[0]['items']] == ['C-own']
|
||||
assert [item['sampleCode'] for item in detail['items']] == ['C-own']
|
||||
assert rows[0]['createBy'] == detail['createBy'] == 'u1'
|
||||
assert rows[0]['createByName'] == detail['createByName'] == '专员'
|
||||
assert 'linkedSampleCount' not in rows[0] and 'linkedSampleCount' not in detail
|
||||
assert 'hasHiddenItems' not in detail
|
||||
|
||||
|
||||
def test_freight_creator_name_uses_historical_nickname_without_id_fallback():
|
||||
from app.services.market_freight import get_freight_detail, list_freights
|
||||
|
||||
db = _session()
|
||||
now = datetime(2026, 10, 11, 8, 0)
|
||||
db.add_all(
|
||||
[
|
||||
SysUser(id='deleted-creator', dept_id='', user_no='deleted-no', nick_name='历史创建人', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=1),
|
||||
SysUser(id='blank-creator', dept_id='', user_no='raw-creator-no', nick_name='', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=1),
|
||||
]
|
||||
)
|
||||
first_sample, first_parent = _sample(db, 'creator-history')
|
||||
second_sample, second_parent = _sample(db, 'creator-blank')
|
||||
historical = _freight(
|
||||
db,
|
||||
'creator-history',
|
||||
[('cell_storage', first_sample, first_parent)],
|
||||
create_by='deleted-creator',
|
||||
)
|
||||
blank = _freight(
|
||||
db,
|
||||
'creator-blank',
|
||||
[('cell_storage', second_sample, second_parent)],
|
||||
create_by='blank-creator',
|
||||
)
|
||||
|
||||
items, total = list_freights(db, ctx=_ctx(), user_info=_user())
|
||||
by_id = {item['id']: item for item in items}
|
||||
historical_detail = get_freight_detail(
|
||||
db, historical.id, ctx=_ctx(), user_info=_user()
|
||||
)
|
||||
blank_detail = get_freight_detail(
|
||||
db, blank.id, ctx=_ctx(), user_info=_user()
|
||||
)
|
||||
|
||||
assert total == 2
|
||||
assert by_id[historical.id]['createByName'] == '历史创建人'
|
||||
assert historical_detail['createByName'] == '历史创建人'
|
||||
assert by_id[blank.id]['createByName'] == ''
|
||||
assert blank_detail['createByName'] == ''
|
||||
|
||||
|
||||
def test_batch_without_visible_item_is_not_listed_or_addressable():
|
||||
from app.services.market_freight import get_freight_detail, list_freights
|
||||
db = _session()
|
||||
|
||||
@@ -20,8 +20,13 @@ from app.models.models import (
|
||||
BizProtocolCollectionInfo,
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolPayment,
|
||||
BizProtocolPaymentDetail,
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolSample,
|
||||
BizProtocolSampleTransport,
|
||||
BizSampleCode,
|
||||
BizSampleReception,
|
||||
SysPost,
|
||||
SysPostRole,
|
||||
SysRole,
|
||||
@@ -49,8 +54,13 @@ def _session():
|
||||
BizProtocolPartyCustomer.__table__,
|
||||
BizProtocolCollectionInfo.__table__,
|
||||
BizProtocolSample.__table__,
|
||||
BizProtocolSampleTransport.__table__,
|
||||
BizProtocolPayment.__table__,
|
||||
BizProtocolPaymentDetail.__table__,
|
||||
BizProtocolPaymentSchedule.__table__,
|
||||
BizProtocolPaymentTransfer.__table__,
|
||||
BizSampleCode.__table__,
|
||||
BizSampleReception.__table__,
|
||||
BizResearchDonation.__table__,
|
||||
BizResearchDonationParty.__table__,
|
||||
BizResearchDonationCollection.__table__,
|
||||
@@ -394,6 +404,161 @@ def test_protocol_detail_can_resolve_source_from_scoped_record():
|
||||
assert result['basic']['id'] == 'don-own'
|
||||
|
||||
|
||||
def test_protocol_detail_resolves_web_display_names_for_mobile_fields():
|
||||
from app.services.market_protocol import get_protocol
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
row = _cell(db, 'named')
|
||||
row.collector_user_id = 'u1'
|
||||
collection = db.query(BizProtocolCollectionInfo).filter_by(
|
||||
collection_protocol_id=row.id
|
||||
).one()
|
||||
collection.hospital_id = 'hospital-a'
|
||||
db.add(
|
||||
BizProtocolSample(
|
||||
id='sample-named',
|
||||
collection_protocol_id=row.id,
|
||||
sample_type_id='type-a',
|
||||
sample_code='S-NAMED',
|
||||
unit_id='unit-a',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = get_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=row.id,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
|
||||
assert result['basic']['collectorUserName'] == '当前用户'
|
||||
assert result['collection']['regionName'] == 'A区'
|
||||
assert result['collection']['hospitalName'] == 'A院'
|
||||
assert result['collection']['salesUserName'] == '当前用户'
|
||||
assert result['collection']['regionManagerUserName'] == '经理'
|
||||
assert result['samples'][0]['sampleTypeName'] == '样本A'
|
||||
assert result['samples'][0]['unitName'] == '毫升'
|
||||
|
||||
|
||||
def test_protocol_detail_resolves_soft_deleted_master_names_with_tenant_scope():
|
||||
from app.services.market_protocol import get_protocol
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
row = _cell(db, 'historical-master')
|
||||
collection = db.query(BizProtocolCollectionInfo).filter_by(
|
||||
collection_protocol_id=row.id
|
||||
).one()
|
||||
collection.hospital_id = 'hospital-a'
|
||||
db.get(BizCollectionRegion, 'region-a').del_flag = 1
|
||||
db.get(BizCollectionHospital, 'hospital-a').del_flag = 1
|
||||
db.get(BizCellSampleType, 'type-a').del_flag = 1
|
||||
db.get(BizItemUnit, 'unit-a').del_flag = 1
|
||||
db.add_all(
|
||||
[
|
||||
BizCellSampleType(id='type-global-history', type_code='GLOBAL', type_name='历史全局样本', tenant_no='', create_dept_id='', del_flag=1),
|
||||
BizCellSampleType(id='type-cross-history', type_code='CROSS', type_name='跨租户样本名称', tenant_no='tenant-b', create_dept_id='', del_flag=1),
|
||||
BizItemUnit(id='unit-cross-history', unit_code='cross', unit_name='跨租户单位名称', tenant_no='tenant-b', create_dept_id='', del_flag=1),
|
||||
BizProtocolSample(id='sample-history-local', collection_protocol_id=row.id, sample_type_id='type-a', sample_code='S-HISTORY-LOCAL', unit_id='unit-a', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
BizProtocolSample(id='sample-history-global', collection_protocol_id=row.id, sample_type_id='type-global-history', sample_code='S-HISTORY-GLOBAL', unit_id='', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
BizProtocolSample(id='sample-history-cross', collection_protocol_id=row.id, sample_type_id='type-cross-history', sample_code='S-HISTORY-CROSS', unit_id='unit-cross-history', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = get_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=row.id,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
samples = {item['sampleCode']: item for item in result['samples']}
|
||||
|
||||
assert result['collection']['regionName'] == 'A区'
|
||||
assert result['collection']['hospitalName'] == 'A院'
|
||||
assert samples['S-HISTORY-LOCAL']['sampleTypeName'] == '样本A'
|
||||
assert samples['S-HISTORY-LOCAL']['unitName'] == '毫升'
|
||||
assert samples['S-HISTORY-GLOBAL']['sampleTypeName'] == '历史全局样本'
|
||||
assert samples['S-HISTORY-CROSS']['sampleTypeName'] == ''
|
||||
assert samples['S-HISTORY-CROSS']['unitName'] == ''
|
||||
|
||||
|
||||
def test_protocol_names_use_exact_historical_nicknames_without_raw_fallbacks():
|
||||
from app.services.market_protocol import get_protocol, list_protocols
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
row = _cell(db, 'historical-names')
|
||||
row.collector_user_id = 'blank-name-user'
|
||||
collection = db.query(BizProtocolCollectionInfo).filter_by(
|
||||
collection_protocol_id=row.id
|
||||
).one()
|
||||
collection.region_manager_user_id = 'manager-a'
|
||||
current_user = db.get(SysUser, 'u1')
|
||||
current_user.nick_name = '历史删除专员'
|
||||
current_user.del_flag = 1
|
||||
manager = db.get(SysUser, 'manager-a')
|
||||
manager.nick_name = '历史停用经理'
|
||||
manager.status = '0'
|
||||
db.add(
|
||||
SysUser(
|
||||
id='blank-name-user',
|
||||
user_no='raw-collector-no',
|
||||
nick_name='',
|
||||
status='0',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=1,
|
||||
)
|
||||
)
|
||||
sample_type = db.get(BizCellSampleType, 'type-a')
|
||||
sample_type.type_name = ''
|
||||
unit = db.get(BizItemUnit, 'unit-a')
|
||||
unit.unit_name = ''
|
||||
db.add(
|
||||
BizProtocolSample(
|
||||
id='sample-historical-names',
|
||||
collection_protocol_id=row.id,
|
||||
sample_type_id='type-a',
|
||||
sample_code='S-HISTORY',
|
||||
unit_id='unit-a',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items, total = list_protocols(
|
||||
db, ctx=_ctx('specialist'), user_info=_user()
|
||||
)
|
||||
detail = get_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=row.id,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert items[0]['salesUserName'] == '历史删除专员'
|
||||
assert items[0]['collectorUserName'] == ''
|
||||
assert items[0]['managerName'] == '历史停用经理'
|
||||
assert items[0]['sampleTypeName'] == ''
|
||||
assert detail['basic']['collectorUserName'] == ''
|
||||
assert detail['collection']['salesUserName'] == '历史删除专员'
|
||||
assert detail['collection']['regionManagerUserName'] == '历史停用经理'
|
||||
assert detail['samples'][0]['sampleTypeName'] == ''
|
||||
assert detail['samples'][0]['unitName'] == ''
|
||||
|
||||
|
||||
def test_specialist_cell_create_forces_current_collection_and_collector_users():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
@@ -883,14 +1048,115 @@ def test_manager_can_update_donation_in_scope_but_cannot_move_it_out():
|
||||
assert collection.region_manager_user_id == 'u1'
|
||||
|
||||
|
||||
def test_protocol_routes_do_not_expose_audit_void_refund_or_conversion():
|
||||
def test_protocol_routes_expose_scoped_audit_and_delete_but_not_other_admin_mutations():
|
||||
from app.api.market_mobile import router
|
||||
|
||||
paths = {route.path for route in router.routes}
|
||||
disallowed = ('audit', 'void', 'refund', 'convert', 'to-research', 'to-storage')
|
||||
protocol_paths = [path for path in paths if '/protocols' in path]
|
||||
assert protocol_paths
|
||||
assert all(not any(word in path for word in disallowed) for path in protocol_paths)
|
||||
for scope in ('specialist', 'manager'):
|
||||
prefix = f'/mini/market/{scope}/protocols/{{protocol_id}}'
|
||||
routes = {
|
||||
route.path: route.methods
|
||||
for route in router.routes
|
||||
if route.path.startswith(f'/mini/market/{scope}/protocols')
|
||||
}
|
||||
assert routes[f'{prefix}/audit'] == {'POST'}
|
||||
assert 'DELETE' in routes[prefix]
|
||||
assert all(not any(word in path for word in ('void', 'refund', 'convert', 'to-research', 'to-storage')) for path in routes)
|
||||
|
||||
|
||||
def test_protocol_audit_and_delete_recheck_scoped_access_before_crud(monkeypatch):
|
||||
from app.services.market_protocol import audit_protocol, delete_protocol
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
own = _cell(db, 'own', sales='u1')
|
||||
other = _cell(db, 'other', sales='u2')
|
||||
db.commit()
|
||||
audit_calls = []
|
||||
delete_calls = []
|
||||
monkeypatch.setattr(
|
||||
'app.services.market_protocol.cell_crud.create_audit',
|
||||
lambda *args, **kwargs: audit_calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.services.market_protocol.cell_crud.soft_delete_collection_protocol',
|
||||
lambda *args, **kwargs: delete_calls.append((args, kwargs)),
|
||||
)
|
||||
|
||||
audit_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=own.id,
|
||||
audit_status='shtg',
|
||||
audit_remark='通过',
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
delete_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=own.id,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
assert audit_calls[0]['collection_protocol_id'] == own.id
|
||||
assert delete_calls[0][0][1] == own.id
|
||||
|
||||
with pytest.raises(HTTPException, match='无权'):
|
||||
audit_protocol(
|
||||
db,
|
||||
source='cell_storage',
|
||||
protocol_id=other.id,
|
||||
audit_status='shtg',
|
||||
audit_remark='',
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
)
|
||||
|
||||
|
||||
def test_protocol_list_contains_mobile_card_fields():
|
||||
from app.services.market_protocol import list_protocols
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
row = _cell(db, 'card')
|
||||
row.protocol_total_amount = 20000
|
||||
db.add_all([
|
||||
BizProtocolPartyCustomer(
|
||||
id='party-card', collection_protocol_id=row.id, mother_name='李安然',
|
||||
mother_phone='13800138000', mother_id_card='340100', father_name='王先生',
|
||||
newborn_sex='1', delivery_mode='sc', newborn_weight_gram=3200,
|
||||
tenant_no='tenant-a', create_dept_id='', del_flag=0,
|
||||
),
|
||||
BizProtocolSample(
|
||||
id='sample-card', collection_protocol_id=row.id, sample_type_id='type-a',
|
||||
sample_code='S-CARD', tenant_no='tenant-a', create_dept_id='', del_flag=0,
|
||||
),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user())
|
||||
|
||||
assert total == 1
|
||||
assert items[0]['motherName'] == '李安然'
|
||||
assert items[0]['sampleCode'] == 'S-CARD'
|
||||
assert items[0]['sampleTypeName'] == '样本A'
|
||||
assert items[0]['regionName'] == 'A区'
|
||||
assert items[0]['motherPhone'] == '13800138000'
|
||||
assert items[0]['motherIdCard'] == '340100'
|
||||
assert items[0]['fatherName'] == '王先生'
|
||||
assert items[0]['newbornSex'] == '1'
|
||||
assert items[0]['deliveryMode'] == 'sc'
|
||||
assert items[0]['newbornWeightGram'] == 3200
|
||||
assert items[0]['salesUserName'] == '当前用户'
|
||||
|
||||
by_customer, customer_total = list_protocols(
|
||||
db, ctx=_ctx('specialist'), user_info=_user(), keyword='李安然'
|
||||
)
|
||||
by_sample, sample_total = list_protocols(
|
||||
db, ctx=_ctx('specialist'), user_info=_user(), keyword='S-CARD'
|
||||
)
|
||||
assert customer_total == sample_total == 1
|
||||
assert by_customer[0]['id'] == by_sample[0]['id'] == row.id
|
||||
|
||||
|
||||
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import get_args
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
@@ -530,6 +532,154 @@ def test_sample_form_options_return_only_enabled_staff_in_scoped_document_region
|
||||
assert result == {'staff': [{'value': 'eligible', 'label': '可办理'}]}
|
||||
|
||||
|
||||
def test_sample_detail_and_options_resolve_only_referenced_historical_handlers():
|
||||
from app.services.market_sample import get_sample_detail, get_sample_form_options
|
||||
|
||||
db = _session()
|
||||
protocol, _, sample = _cell_sample(db, 'historical-handlers', region='r1')
|
||||
db.add_all(
|
||||
[
|
||||
SysUser(id='eligible', user_no='eligible-no', nick_name='当前可办理', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
SysUser(id='disabled-history', user_no='disabled-no', nick_name='历史停用人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
SysUser(id='deleted-history', user_no='deleted-no', nick_name='历史删除人员', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=1),
|
||||
SysUser(id='unrelated-history', user_no='unrelated-no', nick_name='无关历史人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
|
||||
SysUser(id='cross-history', user_no='cross-no', nick_name='跨租户历史人员', email='', phone_no='', status='0', tenant_no='tenant-b', create_dept_id='', del_flag=1),
|
||||
BizCollectionRegionStaff(id='region-eligible-history', region_id='r1', user_id='eligible', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
BizProtocolStorageCert(id='cert-history', collection_protocol_id=protocol.id, handler_user_id='disabled-history', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
BizProtocolFullmoonTodo(id='todo-history', collection_protocol_id=protocol.id, handler_user_id='deleted-history', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
BizSampleReception(id='reception-history', protocol_sample_id=sample.id, collection_protocol_id=protocol.id, reception_time=datetime(2026, 10, 2, 8, 0), accept_conclusion='js', reception_source='cell_storage', sample_code=sample.sample_code, lab_storage_code='LAB-2026-001', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
detail = get_sample_detail(
|
||||
db,
|
||||
sample_id='cell_storage:C-historical-handlers',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
)
|
||||
handles = next(
|
||||
section for section in detail['sections'] if section['key'] == 'market-handles'
|
||||
)
|
||||
basic = next(section for section in detail['sections'] if section['key'] == 'basic')
|
||||
storage_options = get_sample_form_options(
|
||||
db,
|
||||
sample_id='cell_storage:C-historical-handlers',
|
||||
action='storage-cert',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
)
|
||||
insurance_options = get_sample_form_options(
|
||||
db,
|
||||
sample_id='cell_storage:C-historical-handlers',
|
||||
action='insurance',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
)
|
||||
default_options = get_sample_form_options(
|
||||
db,
|
||||
sample_id='cell_storage:C-historical-handlers',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
)
|
||||
|
||||
assert handles['storageCert']['handlerUserName'] == '历史停用人员'
|
||||
assert handles['insurance']['handlerUserName'] == '历史删除人员'
|
||||
assert {'label': '实验室入库编码', 'value': 'LAB-2026-001'} in basic['fields']
|
||||
assert {item['value']: item['label'] for item in storage_options['staff']} == {
|
||||
'eligible': '当前可办理',
|
||||
'disabled-history': '历史停用人员',
|
||||
}
|
||||
assert {item['value']: item['label'] for item in insurance_options['staff']} == {
|
||||
'eligible': '当前可办理',
|
||||
'deleted-history': '历史删除人员',
|
||||
}
|
||||
assert default_options == {
|
||||
'staff': [{'value': 'eligible', 'label': '当前可办理'}]
|
||||
}
|
||||
|
||||
|
||||
def test_sample_form_options_route_validates_and_forwards_action(monkeypatch):
|
||||
from app.api import market_mobile
|
||||
|
||||
route = next(
|
||||
route
|
||||
for route in market_mobile.router.routes
|
||||
if route.path == '/mini/market/specialist/samples/form-options'
|
||||
)
|
||||
action_param = next(
|
||||
param for param in route.dependant.query_params if param.name == 'action'
|
||||
)
|
||||
literal_type = next(
|
||||
value
|
||||
for value in get_args(action_param.field_info.annotation)
|
||||
if get_args(value)
|
||||
)
|
||||
assert set(get_args(literal_type)) == {
|
||||
'storage-cert',
|
||||
'insurance',
|
||||
}
|
||||
captured = {}
|
||||
|
||||
def fake_form_options(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {'staff': []}
|
||||
|
||||
monkeypatch.setattr(
|
||||
market_mobile.market_sample,
|
||||
'get_sample_form_options',
|
||||
fake_form_options,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
route.endpoint(
|
||||
sample_id='cell_storage:C-action',
|
||||
action='insurance',
|
||||
user=_user(),
|
||||
db=object(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result['code'] == 200
|
||||
assert captured['action'] == 'insurance'
|
||||
|
||||
|
||||
def test_sample_list_batches_referenced_handler_name_lookup():
|
||||
from app.services.market_sample import list_samples
|
||||
|
||||
db = _session()
|
||||
db.add(
|
||||
SysUser(id='history-handler', user_no='history-no', nick_name='历史办理人', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1)
|
||||
)
|
||||
for index in range(5):
|
||||
protocol, _, _ = _cell_sample(db, f'handler-batch-{index}')
|
||||
db.add(
|
||||
BizProtocolStorageCert(
|
||||
id=f'cert-handler-batch-{index}',
|
||||
collection_protocol_id=protocol.id,
|
||||
handler_user_id='history-handler',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
statements = []
|
||||
|
||||
def capture_statement(_conn, _cursor, statement, _parameters, _context, _many):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(db.get_bind(), 'before_cursor_execute', capture_statement)
|
||||
try:
|
||||
items, total = list_samples(db, ctx=_ctx(), user_info=_user(), page_size=10)
|
||||
finally:
|
||||
event.remove(db.get_bind(), 'before_cursor_execute', capture_statement)
|
||||
|
||||
assert total == len(items) == 5
|
||||
assert {item['storageCert']['handlerUserName'] for item in items} == {'历史办理人'}
|
||||
assert sum('FROM sys_user' in statement for statement in statements) == 1
|
||||
|
||||
|
||||
def test_sample_handler_must_be_enabled_staff_in_current_document_region():
|
||||
from app.services.market_sample import update_storage_cert
|
||||
|
||||
|
||||
@@ -10,12 +10,17 @@ from app.models.models import (
|
||||
BizAttachment,
|
||||
BizCellSampleType,
|
||||
BizCollectionHospital,
|
||||
BizCollectionProtocol,
|
||||
BizCollectionRegion,
|
||||
BizCollectionRegionStaff,
|
||||
BizItemUnit,
|
||||
BizProtocolSample,
|
||||
BizSampleCode,
|
||||
SysDictData,
|
||||
SysEthnic,
|
||||
SysUser,
|
||||
)
|
||||
from app.models.research_donation import BizResearchDonationSample
|
||||
from app.services.market_field_scope import MarketFieldContext
|
||||
|
||||
|
||||
@@ -27,10 +32,15 @@ def _session():
|
||||
BizAttachment.__table__,
|
||||
BizCellSampleType.__table__,
|
||||
BizCollectionHospital.__table__,
|
||||
BizCollectionProtocol.__table__,
|
||||
BizCollectionRegion.__table__,
|
||||
BizCollectionRegionStaff.__table__,
|
||||
BizItemUnit.__table__,
|
||||
BizProtocolSample.__table__,
|
||||
BizResearchDonationSample.__table__,
|
||||
BizSampleCode.__table__,
|
||||
SysDictData.__table__,
|
||||
SysEthnic.__table__,
|
||||
SysUser.__table__,
|
||||
],
|
||||
)
|
||||
@@ -72,6 +82,8 @@ def _seed_options(db):
|
||||
BizCellSampleType(id='type-a', type_code='A', type_name='样本A', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
BizCellSampleType(id='type-x', type_code='X', type_name='跨租户秘密样本', tenant_no='tenant-b', create_dept_id='', del_flag=0),
|
||||
BizItemUnit(id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
BizSampleCode(id='sample-code-a', sample_code='S-001', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
SysEthnic(id='ethnic-han', ethnic_name='汉族', ethnic_code='01', tenant_no='', create_dept_id='', del_flag=0),
|
||||
SysDictData(id='dict-pay', dict_label='微信', dict_value='wx', dict_type='zzfs', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
SysDictData(id='dict-freight', dict_label='专线车', dict_value='zxc', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
SysDictData(id='dict-secret', dict_label='不应泄漏', dict_value='secret', dict_type='unrelated', tenant_no='tenant-a', create_dept_id='', del_flag=0),
|
||||
@@ -108,6 +120,7 @@ def test_specialist_form_options_are_region_scoped_and_minimal(monkeypatch):
|
||||
{'label': '已作废', 'value': 'void'},
|
||||
]
|
||||
assert out['transferTypes'] == [{'value': 'wx', 'label': '微信'}]
|
||||
assert out['currentUser'] == {'value': 'user-a', 'label': '本人'}
|
||||
assert '跨租户' not in str(out)
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -142,6 +155,10 @@ def test_manager_form_options_only_include_led_regions_and_requested_region_must
|
||||
)
|
||||
assert out['regions'] == [{'value': 'rb', 'label': 'B区'}]
|
||||
assert out['hospitals'] == [{'value': 'hb', 'label': 'B院'}]
|
||||
assert out['ethnicities'] == [{'value': '汉族', 'label': '汉族'}]
|
||||
assert out['sampleCodes'] == [
|
||||
{'value': 'sample-code-a', 'label': 'S-001', 'sampleCode': 'S-001'}
|
||||
]
|
||||
|
||||
with pytest.raises(PermissionError, match='地区'):
|
||||
get_form_options(
|
||||
@@ -153,6 +170,65 @@ def test_manager_form_options_only_include_led_regions_and_requested_region_must
|
||||
)
|
||||
|
||||
|
||||
def test_protocol_form_options_hide_occupied_sample_codes():
|
||||
from app.services.market_support import get_form_options
|
||||
|
||||
db = _session()
|
||||
_seed_options(db)
|
||||
db.add(BizSampleCode(id='sample-code-b', sample_code='S-002', tenant_no='tenant-a', create_dept_id='', del_flag=0))
|
||||
db.add(
|
||||
BizCollectionProtocol(
|
||||
id='proto-used',
|
||||
protocol_no='CP-USED',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizProtocolSample(
|
||||
id='ps-used',
|
||||
collection_protocol_id='proto-used',
|
||||
sample_code='S-001',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
out = get_form_options(
|
||||
db,
|
||||
module='protocol',
|
||||
ctx=_ctx('manager', 'user-b'),
|
||||
user_info=_user('user-b'),
|
||||
region_id='rb',
|
||||
)
|
||||
assert out['sampleCodes'] == [
|
||||
{'value': 'sample-code-b', 'label': 'S-002', 'sampleCode': 'S-002'}
|
||||
]
|
||||
|
||||
|
||||
def test_presign_form_options_search_active_tenant_users_without_pii():
|
||||
from app.services.market_support import get_form_options
|
||||
|
||||
db = _session()
|
||||
_seed_options(db)
|
||||
|
||||
out = get_form_options(
|
||||
db,
|
||||
module='presign',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
user_keyword='员工',
|
||||
)
|
||||
|
||||
assert out['users'] == [{'value': 'staff-a', 'label': 'A员工'}]
|
||||
assert '跨租户' not in str(out['users'])
|
||||
assert '停用' not in str(out['users'])
|
||||
assert 'userNo' not in str(out['users'])
|
||||
|
||||
|
||||
def test_payment_and_freight_options_use_fixed_dictionary_allowlist():
|
||||
from app.services.market_support import get_form_options
|
||||
|
||||
@@ -393,6 +469,219 @@ def _attachment(**overrides):
|
||||
return BizAttachment(**values)
|
||||
|
||||
|
||||
class _DownloadStorage:
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
|
||||
def presigned_download_url(self, file_url, file_name):
|
||||
self.requests.append((file_url, file_name))
|
||||
return 'https://files.test/signed-download'
|
||||
|
||||
|
||||
def test_market_attachment_download_uses_registered_original_name():
|
||||
from app.services.market_support import get_market_attachment_download_url
|
||||
|
||||
db = _session()
|
||||
db.add(_attachment())
|
||||
db.commit()
|
||||
backend = _DownloadStorage()
|
||||
|
||||
result = get_market_attachment_download_url(
|
||||
db,
|
||||
file_url='market/specialist/payment/a.pdf',
|
||||
file_name='spoofed.exe',
|
||||
module='payment',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
storage_backend=backend,
|
||||
)
|
||||
|
||||
assert result == {'downloadUrl': 'https://files.test/signed-download'}
|
||||
assert backend.requests == [
|
||||
('market/specialist/payment/a.pdf', 'proof.pdf')
|
||||
]
|
||||
|
||||
|
||||
def test_market_attachment_download_rejects_cross_tenant_module_and_other_users_staging():
|
||||
from app.services.market_support import get_market_attachment_download_url
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
_attachment(id='cross', file_url='cross.pdf', tenant_no='tenant-b'),
|
||||
_attachment(
|
||||
id='wrong-module',
|
||||
file_url='wrong-module.pdf',
|
||||
business_type='market:specialist:sample:bound',
|
||||
business_id='sample-a',
|
||||
),
|
||||
_attachment(
|
||||
id='other-staging',
|
||||
file_url='other-staging.pdf',
|
||||
create_by='user-b',
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
for file_url in ('cross.pdf', 'wrong-module.pdf', 'other-staging.pdf'):
|
||||
with pytest.raises(PermissionError, match='附件'):
|
||||
get_market_attachment_download_url(
|
||||
db,
|
||||
file_url=file_url,
|
||||
file_name=None,
|
||||
module='payment',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
storage_backend=_DownloadStorage(),
|
||||
)
|
||||
|
||||
|
||||
def test_market_attachment_download_rejects_unreferenced_bound_and_legacy_rows(
|
||||
monkeypatch,
|
||||
):
|
||||
from app.services import market_support
|
||||
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
_attachment(
|
||||
id='manager-bound',
|
||||
file_url='manager-bound.pdf',
|
||||
business_type='market:manager:payment:bound',
|
||||
business_id='transfer-a',
|
||||
),
|
||||
_attachment(
|
||||
id='legacy',
|
||||
file_url='legacy.pdf',
|
||||
business_type=None,
|
||||
create_by='admin',
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
monkeypatch.setattr(
|
||||
market_support,
|
||||
'_has_accessible_market_attachment_reference',
|
||||
lambda *args, **kwargs: False,
|
||||
)
|
||||
|
||||
for file_url in ('manager-bound.pdf', 'legacy.pdf'):
|
||||
with pytest.raises(PermissionError, match='附件'):
|
||||
market_support.get_market_attachment_download_url(
|
||||
db,
|
||||
file_url=file_url,
|
||||
file_name=None,
|
||||
module='payment',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
storage_backend=_DownloadStorage(),
|
||||
)
|
||||
|
||||
|
||||
def test_market_attachment_download_allows_accessible_referenced_legacy_row(monkeypatch):
|
||||
from app.services import market_support
|
||||
|
||||
db = _session()
|
||||
db.add(
|
||||
_attachment(
|
||||
id='legacy',
|
||||
file_url='legacy.pdf',
|
||||
business_type=None,
|
||||
create_by='admin',
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
checked = []
|
||||
|
||||
def allow_reference(_db, *, module, candidates, ctx, user_info):
|
||||
checked.append((module, candidates, ctx.user_id, user_info['user_id']))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
market_support,
|
||||
'_has_accessible_market_attachment_reference',
|
||||
allow_reference,
|
||||
)
|
||||
|
||||
result = market_support.get_market_attachment_download_url(
|
||||
db,
|
||||
file_url='legacy.pdf',
|
||||
file_name=None,
|
||||
module='payment',
|
||||
ctx=_ctx(),
|
||||
user_info=_user(),
|
||||
storage_backend=_DownloadStorage(),
|
||||
)
|
||||
|
||||
assert result['downloadUrl'] == 'https://files.test/signed-download'
|
||||
assert checked == [('payment', ['legacy.pdf'], 'user-a', 'user-a')]
|
||||
|
||||
|
||||
def test_protocol_attachment_reference_respects_specialist_donation_scope():
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolCollectionInfo,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation,
|
||||
BizResearchDonationCollection,
|
||||
)
|
||||
from app.services import market_support
|
||||
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
BizCollectionProtocol.__table__,
|
||||
BizProtocolCollectionInfo.__table__,
|
||||
BizResearchDonation.__table__,
|
||||
BizResearchDonationCollection.__table__,
|
||||
],
|
||||
)
|
||||
db = sessionmaker(bind=engine)()
|
||||
db.add_all(
|
||||
[
|
||||
BizResearchDonation(
|
||||
id='donation-a',
|
||||
donation_no='D-1',
|
||||
donation_status='sxz',
|
||||
source_type='direct',
|
||||
attachment_urls='legacy.pdf',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=0,
|
||||
),
|
||||
BizResearchDonationCollection(
|
||||
id='collection-a',
|
||||
research_donation_id='donation-a',
|
||||
sales_user_id='user-a',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
super_admin_a = {**_user('user-a'), 'role': 'super_admin'}
|
||||
super_admin_b = {**_user('user-b'), 'role': 'super_admin'}
|
||||
|
||||
assert market_support._has_accessible_market_attachment_reference(
|
||||
db,
|
||||
module='protocol',
|
||||
candidates=['legacy.pdf'],
|
||||
ctx=_ctx(user_id='user-a'),
|
||||
user_info=super_admin_a,
|
||||
)
|
||||
assert not market_support._has_accessible_market_attachment_reference(
|
||||
db,
|
||||
module='protocol',
|
||||
candidates=['legacy.pdf'],
|
||||
ctx=_ctx(user_id='user-b'),
|
||||
user_info=super_admin_b,
|
||||
)
|
||||
|
||||
|
||||
def test_create_attachment_refs_require_current_uploader_staging_exact_match():
|
||||
from app.services.market_support import validate_attachment_refs
|
||||
|
||||
@@ -673,7 +962,7 @@ def test_allowed_actions_are_state_scope_and_permission_aware():
|
||||
assert allowed_actions('presign-transfer', status='dfj') == ['edit', 'delete', 'submit']
|
||||
assert allowed_actions('presign-transfer', status='jfqrz') == ['withdraw']
|
||||
assert allowed_actions('presign-transfer', status='yjf') == []
|
||||
assert allowed_actions('protocol', status='dsx') == ['edit']
|
||||
assert allowed_actions('protocol', status='dsx') == ['audit', 'edit', 'delete']
|
||||
assert allowed_actions('protocol', status='yzz') == []
|
||||
assert allowed_actions('payment-transfer', status='dfj') == ['edit-transfer', 'submit-finance']
|
||||
assert allowed_actions('payment-transfer', status='jfqrz') == ['withdraw-transfer']
|
||||
@@ -897,7 +1186,7 @@ def test_protocol_detail_action_reads_nested_basic_status():
|
||||
terminal = decorate_allowed_actions(
|
||||
'protocol', {'basic': {'protocolStatus': 'yzf'}}, scope_kind='specialist'
|
||||
)
|
||||
assert active['allowedActions'] == ['edit']
|
||||
assert active['allowedActions'] == ['audit', 'edit', 'delete']
|
||||
assert terminal['allowedActions'] == []
|
||||
|
||||
|
||||
@@ -916,9 +1205,13 @@ def test_market_support_routes_are_fixed_and_registered_before_dynamic_details()
|
||||
assert path in paths
|
||||
for module in ('presign', 'protocol', 'payment', 'sample'):
|
||||
upload_path = f'/mini/market/{scope}/{module}/attachments'
|
||||
download_path = f'{upload_path}/download-url'
|
||||
assert upload_path in paths
|
||||
assert download_path in paths
|
||||
upload_route = next(route for route in router.routes if route.path == upload_path)
|
||||
download_route = next(route for route in router.routes if route.path == download_path)
|
||||
assert [param.name for param in upload_route.dependant.query_params] == []
|
||||
assert download_route.methods == {'POST'}
|
||||
assert paths.index(f'/mini/market/{scope}/presign-orders/form-options') < paths.index(
|
||||
f'/mini/market/{scope}/presign-orders/{{order_id}}'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.api.mini_menu import list_mobile_menu_items
|
||||
from app.models.base import Base
|
||||
from app.models.models import (
|
||||
SysMenu,
|
||||
SysPost,
|
||||
SysPostRole,
|
||||
SysRole,
|
||||
SysRoleMenu,
|
||||
SysUserPost,
|
||||
SysUserRole,
|
||||
)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
SysMenu.__table__,
|
||||
SysPost.__table__,
|
||||
SysPostRole.__table__,
|
||||
SysRole.__table__,
|
||||
SysRoleMenu.__table__,
|
||||
SysUserPost.__table__,
|
||||
SysUserRole.__table__,
|
||||
],
|
||||
)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _menu(*, menu_id, tenant_no, name, parent_id, path="", perms=None, menu_type="M", order_num=1):
|
||||
return SysMenu(
|
||||
id=menu_id,
|
||||
menu_name=name,
|
||||
parent_id=parent_id,
|
||||
menu_type=menu_type,
|
||||
path=path,
|
||||
perms=perms,
|
||||
visible="1",
|
||||
status="1",
|
||||
tenant_no=tenant_no,
|
||||
order_num=order_num,
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_menu_query_uses_current_tenant_and_returns_leaf_group_title():
|
||||
db = _session()
|
||||
db.add_all(
|
||||
[
|
||||
_menu(
|
||||
menu_id="tenant-a-root",
|
||||
tenant_no="tenant-a",
|
||||
name="移动端菜单",
|
||||
parent_id="0",
|
||||
),
|
||||
_menu(
|
||||
menu_id="tenant-a-market",
|
||||
tenant_no="tenant-a",
|
||||
name="市场部",
|
||||
parent_id="tenant-a-root",
|
||||
),
|
||||
_menu(
|
||||
menu_id="tenant-a-manager-presign",
|
||||
tenant_no="tenant-a",
|
||||
name="预签约订单(经理)",
|
||||
parent_id="tenant-a-market",
|
||||
path="/pages/market/webview/index?entry=manager-presign",
|
||||
perms="mini:market:manager:presign",
|
||||
menu_type="C",
|
||||
),
|
||||
_menu(
|
||||
menu_id="tenant-b-root",
|
||||
tenant_no="tenant-b",
|
||||
name="移动端菜单",
|
||||
parent_id="0",
|
||||
),
|
||||
_menu(
|
||||
menu_id="tenant-b-market",
|
||||
tenant_no="tenant-b",
|
||||
name="市场部",
|
||||
parent_id="tenant-b-root",
|
||||
),
|
||||
_menu(
|
||||
menu_id="tenant-b-specialist-presign",
|
||||
tenant_no="tenant-b",
|
||||
name="预签约订单(专员)",
|
||||
parent_id="tenant-b-market",
|
||||
path="/pages/market/webview/index?entry=specialist-presign",
|
||||
perms="mini:market:specialist:presign",
|
||||
menu_type="C",
|
||||
),
|
||||
SysRole(
|
||||
id="tenant-b-role",
|
||||
role_name="市场专员",
|
||||
status="1",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysUserRole(
|
||||
id="tenant-b-user-role",
|
||||
user_id="u1",
|
||||
role_id="tenant-b-role",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysRoleMenu(
|
||||
id="tenant-b-role-directory",
|
||||
role_id="tenant-b-role",
|
||||
menu_id="tenant-b-market",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
SysRoleMenu(
|
||||
id="tenant-b-role-leaf",
|
||||
role_id="tenant-b-role",
|
||||
menu_id="tenant-b-specialist-presign",
|
||||
tenant_no="tenant-b",
|
||||
create_dept_id="",
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items = list_mobile_menu_items(db, user_id="u1", tenant_no="tenant-b")
|
||||
|
||||
assert {item["id"] for item in items} == {"tenant-b-specialist-presign"}
|
||||
assert items[0]["groupTitle"] == "市场部"
|
||||
assert items[0]["title"] == "预签约订单(专员)"
|
||||
assert items[0]["path"] == (
|
||||
"/pages/market/webview/index?entry=specialist-presign"
|
||||
)
|
||||
assert items[0]["icon"] == "market-presign"
|
||||
assert items[0]["iconBg"] == "transparent"
|
||||
|
||||
|
||||
def test_mobile_menu_query_requires_explicit_tenant():
|
||||
db = _session()
|
||||
|
||||
assert list_mobile_menu_items(db, user_id="u1", tenant_no="") == []
|
||||
|
||||
@@ -153,5 +153,117 @@ class TestPresignWithdrawReceipt(unittest.TestCase):
|
||||
po._get_transfer_or_raise = orig
|
||||
|
||||
|
||||
class TestPresignProtocolNoAllocate(unittest.TestCase):
|
||||
"""预签约协议编号:保存发号、忽略客户端改号、空号补发。"""
|
||||
|
||||
def test_assert_biz_no_rejects_presign_occupied(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.crud.protocol_donation_convert import assert_biz_no_available
|
||||
from app.models.models import BizCollectionProtocol, BizPresignOrder
|
||||
from app.models.research_donation import BizResearchDonation
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
def query(model):
|
||||
q = MagicMock()
|
||||
if model is BizPresignOrder:
|
||||
q.filter.return_value.all.return_value = [
|
||||
SimpleNamespace(id="presign-1")
|
||||
]
|
||||
else:
|
||||
q.filter.return_value.all.return_value = []
|
||||
return q
|
||||
|
||||
db.query.side_effect = query
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
assert_biz_no_available(db, tenant_no="t1", biz_no="XBCC20261012000001")
|
||||
self.assertIn("协议编号已存在", str(ctx.exception))
|
||||
|
||||
# 排除自身预签后可通过
|
||||
assert_biz_no_available(
|
||||
db,
|
||||
tenant_no="t1",
|
||||
biz_no="XBCC20261012000001",
|
||||
exclude_presign_order_id="presign-1",
|
||||
)
|
||||
# 静默使用导入,避免 lint 误报
|
||||
_ = (BizCollectionProtocol, BizResearchDonation)
|
||||
|
||||
def test_create_allocates_and_ignores_client_protocol_no(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.crud import presign_order as po
|
||||
|
||||
db = MagicMock()
|
||||
with patch.object(
|
||||
po, "allocate_next_biz_no", return_value="XBCC20261012000009"
|
||||
) as alloc, patch.object(po, "replace_sample_types"):
|
||||
obj = po.create_presign_order(
|
||||
db,
|
||||
tenant_no="t1",
|
||||
user_id="u1",
|
||||
fields={"protocolNo": "CLIENT-NO", "motherName": "张三"},
|
||||
commit=False,
|
||||
)
|
||||
self.assertEqual(obj.protocol_no, "XBCC20261012000009")
|
||||
alloc.assert_called_once()
|
||||
self.assertEqual(alloc.call_args.kwargs.get("kind"), "collection")
|
||||
|
||||
def test_update_ignores_client_change_and_backfills_empty(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.crud import presign_order as po
|
||||
|
||||
kept = SimpleNamespace(
|
||||
id="o1",
|
||||
protocol_no="KEEP-NO",
|
||||
order_status="active",
|
||||
pay_mode=None,
|
||||
contract_amount=None,
|
||||
sample_collect_time=None,
|
||||
update_by=None,
|
||||
update_time=None,
|
||||
)
|
||||
empty = SimpleNamespace(
|
||||
id="o2",
|
||||
protocol_no=None,
|
||||
order_status="active",
|
||||
pay_mode=None,
|
||||
contract_amount=None,
|
||||
sample_collect_time=None,
|
||||
update_by=None,
|
||||
update_time=None,
|
||||
)
|
||||
db = MagicMock()
|
||||
with patch.object(po, "_get_order_or_raise", return_value=kept):
|
||||
po.update_presign_order(
|
||||
db,
|
||||
order_id="o1",
|
||||
tenant_no="t1",
|
||||
user_id="u1",
|
||||
fields={"protocolNo": "HACK", "motherName": "李四"},
|
||||
commit=False,
|
||||
)
|
||||
self.assertEqual(kept.protocol_no, "KEEP-NO")
|
||||
self.assertEqual(kept.mother_name, "李四")
|
||||
|
||||
with patch.object(po, "_get_order_or_raise", return_value=empty), patch.object(
|
||||
po, "allocate_next_biz_no", return_value="XBCC20261012000010"
|
||||
) as alloc:
|
||||
po.update_presign_order(
|
||||
db,
|
||||
order_id="o2",
|
||||
tenant_no="t1",
|
||||
user_id="u1",
|
||||
fields={"motherName": "王五"},
|
||||
commit=False,
|
||||
)
|
||||
self.assertEqual(empty.protocol_no, "XBCC20261012000010")
|
||||
alloc.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,7 +3,10 @@ from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from app.services.qc_work_order_fee_summary import build_protocol_payment_summary
|
||||
from app.services.qc_work_order_fee_summary import (
|
||||
build_protocol_payment_summary,
|
||||
pick_earliest_transfer_time_by_protocol,
|
||||
)
|
||||
|
||||
|
||||
class TestPaymentSummaryTransferTime(unittest.TestCase):
|
||||
@@ -23,6 +26,17 @@ class TestPaymentSummaryTransferTime(unittest.TestCase):
|
||||
self.assertIsNotNone(summary)
|
||||
self.assertEqual(summary["paymentDetails"][0]["transferTime"], "2025-09-15 09:20:00")
|
||||
|
||||
def test_pick_earliest_transfer_time_by_protocol(self):
|
||||
rows = [
|
||||
("p1", datetime(2025, 9, 20, 14, 30, 0)),
|
||||
("p1", datetime(2025, 9, 15, 9, 20, 0)),
|
||||
("p1", datetime(2025, 9, 18, 8, 0, 0)),
|
||||
("p2", datetime(2025, 10, 1, 10, 0, 0)),
|
||||
]
|
||||
got = pick_earliest_transfer_time_by_protocol(rows)
|
||||
self.assertEqual(got["p1"], datetime(2025, 9, 15, 9, 20, 0))
|
||||
self.assertEqual(got["p2"], datetime(2025, 10, 1, 10, 0, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""协议单样本预计到达时间:运费批次改造后仍应可写回运输表。"""
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.crud.collection_protocol import save_sample_transports
|
||||
from app.crud.research_donation import _create_transport
|
||||
|
||||
|
||||
class TestSaveSampleTransportsEta(unittest.TestCase):
|
||||
@patch("app.crud.collection_protocol.generate_uuid", return_value="tr-1")
|
||||
@patch("app.crud.collection_protocol._soft_delete_transports_for_protocol")
|
||||
def test_accepts_eta_only_without_transport_type(self, _soft_del, _uuid):
|
||||
db = MagicMock()
|
||||
sample = MagicMock()
|
||||
sample.id = "s1"
|
||||
sample.sample_code = "SC001"
|
||||
|
||||
save_sample_transports(
|
||||
db,
|
||||
collection_protocol_id="p1",
|
||||
tenant_no="t1",
|
||||
user_id="u1",
|
||||
saved_samples=[sample],
|
||||
input_samples=[
|
||||
{"transport": {"etaTime": "2026-10-12 14:30:00"}},
|
||||
],
|
||||
)
|
||||
|
||||
self.assertTrue(db.add.called)
|
||||
obj = db.add.call_args[0][0]
|
||||
self.assertEqual(obj.sample_id, "s1")
|
||||
self.assertEqual(obj.eta_time, datetime(2026, 10, 12, 14, 30, 0))
|
||||
self.assertEqual(obj.transport_type, "")
|
||||
|
||||
@patch("app.crud.collection_protocol.generate_uuid", return_value="tr-2")
|
||||
@patch("app.crud.collection_protocol._soft_delete_transports_for_protocol")
|
||||
def test_accepts_top_level_eta_time_for_mobile(self, _soft_del, _uuid):
|
||||
db = MagicMock()
|
||||
sample = MagicMock()
|
||||
sample.id = "s2"
|
||||
sample.sample_code = "SC002"
|
||||
|
||||
save_sample_transports(
|
||||
db,
|
||||
collection_protocol_id="p1",
|
||||
tenant_no="t1",
|
||||
user_id="u1",
|
||||
saved_samples=[sample],
|
||||
input_samples=[{"etaTime": "2026-10-13 09:00:00"}],
|
||||
)
|
||||
|
||||
self.assertTrue(db.add.called)
|
||||
obj = db.add.call_args[0][0]
|
||||
self.assertEqual(obj.eta_time, datetime(2026, 10, 13, 9, 0, 0))
|
||||
|
||||
@patch("app.crud.collection_protocol._soft_delete_transports_for_protocol")
|
||||
def test_skips_when_no_eta_and_no_transport_type(self, _soft_del):
|
||||
db = MagicMock()
|
||||
sample = MagicMock()
|
||||
sample.id = "s3"
|
||||
sample.sample_code = "SC003"
|
||||
|
||||
save_sample_transports(
|
||||
db,
|
||||
collection_protocol_id="p1",
|
||||
tenant_no="t1",
|
||||
user_id="u1",
|
||||
saved_samples=[sample],
|
||||
input_samples=[{"transport": {"waybillNo": "SF123"}}],
|
||||
)
|
||||
|
||||
db.add.assert_not_called()
|
||||
|
||||
|
||||
class TestDonationCreateTransportEta(unittest.TestCase):
|
||||
@patch("app.crud.research_donation.generate_uuid", return_value="dtr-1")
|
||||
def test_create_transport_accepts_eta_only(self, _uuid):
|
||||
db = MagicMock()
|
||||
now = datetime(2026, 10, 12, 10, 0, 0)
|
||||
obj = _create_transport(
|
||||
db,
|
||||
donation_id="d1",
|
||||
sample_id="s1",
|
||||
tenant_no="t1",
|
||||
user_id="u1",
|
||||
raw={"etaTime": "2026-10-15 16:00:00"},
|
||||
order_num=0,
|
||||
now=now,
|
||||
sample_code="SC001",
|
||||
)
|
||||
self.assertIsNotNone(obj)
|
||||
self.assertEqual(obj.eta_time, datetime(2026, 10, 15, 16, 0, 0))
|
||||
self.assertEqual(obj.transport_type, "")
|
||||
db.add.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""样本接收列表:待接收 / 已接收最终排序规则。"""
|
||||
|
||||
import unittest
|
||||
|
||||
from app.crud.sample_reception import (
|
||||
_sort_pending_list_items,
|
||||
_sort_received_list_items,
|
||||
)
|
||||
|
||||
|
||||
class TestSampleReceptionListSort(unittest.TestCase):
|
||||
def test_pending_sorts_by_eta_asc_then_sample_code_desc(self):
|
||||
items = [
|
||||
{"etaTime": "2026-10-12 10:00:00", "sampleCode": "A001"},
|
||||
{"etaTime": "2026-10-12 10:00:00", "sampleCode": "A003"},
|
||||
{"etaTime": "2026-10-11 09:00:00", "sampleCode": "B001"},
|
||||
{"etaTime": "", "sampleCode": "Z999"},
|
||||
{"etaTime": None, "sampleCode": "C002"},
|
||||
]
|
||||
|
||||
_sort_pending_list_items(items)
|
||||
|
||||
self.assertEqual(
|
||||
[x["sampleCode"] for x in items],
|
||||
["B001", "A003", "A001", "Z999", "C002"],
|
||||
)
|
||||
|
||||
def test_received_sorts_by_reception_time_desc_then_sample_code_desc(self):
|
||||
items = [
|
||||
{"receptionTime": "2026-10-12 10:00:00", "sampleCode": "A001"},
|
||||
{"receptionTime": "2026-10-12 10:00:00", "sampleCode": "A003"},
|
||||
{"receptionTime": "2026-10-13 09:00:00", "sampleCode": "B001"},
|
||||
{"receptionTime": "", "sampleCode": "Z999"},
|
||||
]
|
||||
|
||||
_sort_received_list_items(items)
|
||||
|
||||
self.assertEqual(
|
||||
[x["sampleCode"] for x in items],
|
||||
["B001", "A003", "A001", "Z999"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
# Clash Verge 流量监控(10 分钟)
|
||||
|
||||
- 开始:2026-10-10T18:03:13.492312
|
||||
- 结束:2026-10-10T18:13:17.003010
|
||||
- 采样:每 5s,共 120 次(API 失败 0);跟踪连接 97 条
|
||||
- 期间累计(连接字节增量估算):上传 1.35 MB / 下载 1.47 MB / 合计 2.82 MB
|
||||
|
||||
> 字节量来自 Clash `/connections` 采样增量;连接次数来自期间 sidecar 新建日志。
|
||||
|
||||
## 按流量 Top 域名
|
||||
|
||||
| 合计 | 上传 | 下载 | 域名 | 主要出站/节点 | 进程 |
|
||||
|---:|---:|---:|---|---|---|
|
||||
| 1.49 MB | 477.2 KB | 1.03 MB | `chatgpt.com` | 🇯🇵 JPN 日本 02 | Codex (Service) |
|
||||
| 471.0 KB | 435.6 KB | 35.4 KB | `chat.openai.com` | 🇯🇵 JPN 日本 02 | Codex (Service) |
|
||||
| 367.3 KB | 101.8 KB | 265.5 KB | `api2.cursor.sh` | DIRECT | Cursor Helper (Plugin) |
|
||||
| 190.3 KB | 159.0 KB | 31.3 KB | `ab.chatgpt.com` | 🇯🇵 JPN 日本 02 | codex |
|
||||
| 161.3 KB | 104.3 KB | 57.1 KB | `ws.chatgpt.com` | 🇯🇵 JPN 日本 02 | Codex (Service) |
|
||||
| 79.6 KB | 57.0 KB | 22.6 KB | `gateway.icloud.com.cn` | DIRECT | cloudd |
|
||||
| 41.1 KB | 29.2 KB | 11.9 KB | `api3.cursor.sh` | DIRECT | Cursor Helper |
|
||||
| 27.0 KB | 6.6 KB | 20.5 KB | `mtalk.google.com` | 🇯🇵 JPN 日本 02 | Codex (Service) |
|
||||
| 14.1 KB | 6.2 KB | 7.9 KB | `clients4.google.com` | 🇯🇵 JPN 日本 02 | Google Chrome Helper |
|
||||
| 4.0 KB | 3.0 KB | 1.1 KB | `easylist-downloads.adblockplus.org` | DIRECT | Google Chrome Helper |
|
||||
|
||||
## 新建连接:进程分布
|
||||
|
||||
- **127** × `?`
|
||||
- **37** × `Cursor Helper`
|
||||
- **6** × `codex`
|
||||
- **4** × `Google Chrome Helper`
|
||||
- **2** × `cloudd`
|
||||
- **1** × `ChatGPT`
|
||||
|
||||
## 新建连接:出站分布
|
||||
|
||||
- **164** × `DIRECT`
|
||||
- **12** × `OpenAI[🇯🇵 JPN 日本 02]`
|
||||
- **1** × `Google[🇯🇵 JPN 日本 02]`
|
||||
|
||||
## 新建连接:走代理的域名
|
||||
|
||||
- **4** `chatgpt.com` → OpenAI[🇯🇵 JPN 日本 02]
|
||||
- **4** `ab.chatgpt.com` → OpenAI[🇯🇵 JPN 日本 02]
|
||||
- **3** `ws.chatgpt.com` → OpenAI[🇯🇵 JPN 日本 02]
|
||||
- **1** `persistent.oaistatic.com` → OpenAI[🇯🇵 JPN 日本 02]
|
||||
- **1** `clients4.google.com` → Google[🇯🇵 JPN 日本 02]
|
||||
|
||||
## 新建连接:直连域名
|
||||
|
||||
- **152** `api2.cursor.sh` → DIRECT
|
||||
- **10** `api3.cursor.sh` → DIRECT
|
||||
- **2** `gateway.icloud.com.cn` → DIRECT
|
||||
|
||||
## 简要结论
|
||||
|
||||
- 按连接出站估算:代理侧约 **2.34 MB**,直连约 **492.0 KB**。
|
||||
- 流量最大域名:`chatgpt.com`(1.49 MB), `chat.openai.com`(471.0 KB), `api2.cursor.sh`(367.3 KB)。
|
||||
- 期间最常走代理的域名:`chatgpt.com`, `ab.chatgpt.com`, `ws.chatgpt.com`, `persistent.oaistatic.com`, `clients4.google.com`。
|
||||
@@ -0,0 +1,163 @@
|
||||
# Clash Verge Rev 省流量配置备忘
|
||||
|
||||
适用版本:Clash Verge Rev **2.5.8**(其它相近版本界面类似)
|
||||
订阅示例:LinkCube(策略组含 `Proxies` / `OpenAI` / `Apple` / `Microsoft` / `Final` 等)
|
||||
整理日期:2026-10-10
|
||||
|
||||
本文记录本机排查后的结论与已生效改动,便于在另一台电脑上复现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 推荐基础设置
|
||||
|
||||
| 项 | 建议 |
|
||||
|---|---|
|
||||
| 代理模式 | **规则**(不要长期用「全局」) |
|
||||
| 系统代理 | 需要时开启 |
|
||||
| TUN 模式 | **关闭**(不必须时不要开;开了更容易把几乎所有流量送进节点) |
|
||||
| 局域网允许 | 不共享给其它设备时关闭 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 策略组改成直连(界面操作)
|
||||
|
||||
打开左侧 **「代理」**(不是「设置」里的系统代理开关)。
|
||||
|
||||
找到下列策略组,展开后选中 **`DIRECT`**:
|
||||
|
||||
| 策略组 | 改成 | 原因 |
|
||||
|---|---|---|
|
||||
| **Final** | `DIRECT` | 兜底规则 `MATCH,Final`;默认跟 `Proxies` 时,未命中规则的流量都会出国(IP 检测、广告列表、Raycast、订阅域名等) |
|
||||
| **Apple** | `DIRECT` | iCloud / 系统服务等国内一般应直连 |
|
||||
| **Microsoft** | `DIRECT` | 微软遥测等无需走代理 |
|
||||
|
||||
保持需要翻墙的组继续走节点,例如:
|
||||
|
||||
| 策略组 | 建议 |
|
||||
|---|---|
|
||||
| **Proxies** | 选延迟可用的节点(如日本 / 香港) |
|
||||
| **OpenAI** | 继续走代理(ChatGPT 等) |
|
||||
| **Google** | 需要访问 Google 时走代理 |
|
||||
|
||||
### 界面找不到策略组时
|
||||
|
||||
1. 确认打开的是左侧 **「代理」**,不是「设置 / 配置」。
|
||||
2. 页面顶部搜索框输入 `Final` / `Apple` / `Microsoft`。
|
||||
3. 或从菜单栏 Clash 图标右键 → 策略组里切换。
|
||||
|
||||
### 不要只改 Proxies 节点
|
||||
|
||||
若 `Final` / `Apple` / `Microsoft` 仍指向 `Proxies`,只换日本/香港节点**不能**让这些流量直连,它们仍会出国。
|
||||
|
||||
---
|
||||
|
||||
## 3. Cursor 改直连(规则扩展)
|
||||
|
||||
订阅里常见规则:
|
||||
|
||||
```yaml
|
||||
- DOMAIN-SUFFIX,cursor.sh,OpenAI
|
||||
- DOMAIN-SUFFIX,cursorapi.com,OpenAI
|
||||
```
|
||||
|
||||
需要 Cursor 不走机场流量时,在 **配置 → 对应订阅 → 规则(Rules)增强** 中写入(或编辑同名增强文件):
|
||||
|
||||
```yaml
|
||||
# Profile Enhancement Rules Template for Clash Verge
|
||||
|
||||
prepend:
|
||||
- DOMAIN-SUFFIX,cursor.sh,DIRECT
|
||||
- DOMAIN-SUFFIX,cursorapi.com,DIRECT
|
||||
- DOMAIN-SUFFIX,cursor.com,DIRECT
|
||||
|
||||
append: []
|
||||
|
||||
delete: []
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `prepend` 会插到订阅规则**前面**,优先于原来的 `OpenAI` 规则。
|
||||
- 保存后需让配置重新加载(切换配置 / 重新启用订阅 / 重启内核,视版本而定)。
|
||||
- macOS 增强文件路径示例(UID 因机器而异,以界面「规则」增强为准):
|
||||
|
||||
`~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/<rules-uid>.yaml`
|
||||
|
||||
### 注意
|
||||
|
||||
国内直连 Cursor 可能连不上或很慢。若异常,删掉上述三条,或改回:
|
||||
|
||||
```yaml
|
||||
prepend:
|
||||
- DOMAIN-SUFFIX,cursor.sh,OpenAI
|
||||
- DOMAIN-SUFFIX,cursorapi.com,OpenAI
|
||||
- DOMAIN-SUFFIX,cursor.com,OpenAI
|
||||
```
|
||||
|
||||
ChatGPT(`chatgpt.com` / `openai.com`)仍走 `OpenAI` 组,不受影响。
|
||||
|
||||
---
|
||||
|
||||
## 4. 另一台电脑快速 checklist
|
||||
|
||||
1. [ ] 模式:**规则**;TUN:**关**;按需开系统代理
|
||||
2. [ ] 「代理」页:`Final` → **DIRECT**
|
||||
3. [ ] 「代理」页:`Apple` → **DIRECT**
|
||||
4. [ ] 「代理」页:`Microsoft` → **DIRECT**
|
||||
5. [ ] (可选)规则增强 prepend:Cursor 三个域名 → **DIRECT**
|
||||
6. [ ] 重载配置后,在「连接」里抽查:
|
||||
- 百度 / QQ / Apple 国内 → `DIRECT`
|
||||
- ChatGPT → `OpenAI` / 节点
|
||||
- Cursor(若已按 §3 配置)→ `DIRECT`
|
||||
|
||||
---
|
||||
|
||||
## 5. 当时日志结论摘要(便于对照)
|
||||
|
||||
在「系统代理 + 规则、TUN 关闭」下,sidecar 日志大致比例:**大量走代理,少量直连**。
|
||||
|
||||
主要走代理且通常合理:
|
||||
|
||||
- `chatgpt.com`、OpenAI 相关 → `OpenAI`
|
||||
- `api2.cursor.sh` 等(改规则前)→ `OpenAI`
|
||||
- Google 系 → `Google`
|
||||
|
||||
建议改为直连的来源:
|
||||
|
||||
- 命中 **Final / Match** 的杂项(IP 查询站、easylist、linkcube 订阅域名、Raycast 等)
|
||||
- **Apple** 组(`setup.icloud.com`、`api.apple-cloudkit.com` 及部分系统进程)
|
||||
- **Microsoft** 组(如 `mobile.events.data.microsoft.com`)
|
||||
|
||||
国内域名(QQ / 微信 / 百度 / `*.cn` / GeoIP CN)多数已是 `DIRECT`,订阅国内规则基本正常;主要浪费来自 **Final/Apple/Microsoft 默认跟 Proxies**,以及 **ChatGPT + Cursor 长时间使用**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 日志位置(macOS,排查用)
|
||||
|
||||
```text
|
||||
~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/latest.log
|
||||
~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/sidecar/sidecar_latest.log
|
||||
```
|
||||
|
||||
在 sidecar 日志中关注:
|
||||
|
||||
```text
|
||||
match ... using Final[...] # 兜底走代理 → 检查 Final 是否应为 DIRECT
|
||||
match ... using Apple[...] # Apple 是否应直连
|
||||
match ... using Microsoft[...] # Microsoft 是否应直连
|
||||
match DomainSuffix(cursor.sh) using ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 策略组结构参考(LinkCube 类订阅)
|
||||
|
||||
```text
|
||||
Final → Proxies | DIRECT (建议选 DIRECT)
|
||||
Apple → Proxies | DIRECT | 各地区 (建议选 DIRECT)
|
||||
Microsoft → Proxies | DIRECT | 各地区 (建议选 DIRECT)
|
||||
OpenAI → Proxies | 各地区 (无 DIRECT,继续代理)
|
||||
Google → Proxies | 各地区 (无 DIRECT,继续代理)
|
||||
Proxies → 具体节点
|
||||
规则末尾 → GEOIP,CN,DIRECT 然后 MATCH,Final
|
||||
```
|
||||
@@ -0,0 +1,766 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>市场协议单 · 来源双 Tab Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--brand: #0a9f6d;
|
||||
--brand-dark: #087f57;
|
||||
--brand-soft: #e9f9f2;
|
||||
--canvas: #f4f8f6;
|
||||
--surface: #fff;
|
||||
--text: #18332a;
|
||||
--muted: #6b7f77;
|
||||
--line: #dce9e4;
|
||||
--danger: #d14343;
|
||||
--warning: #d98516;
|
||||
--shadow: 0 10px 30px rgba(25, 65, 49, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
background: #eaf1ed;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.demo-stage {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(270px, 330px) 390px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
padding: 26px;
|
||||
border: 1px solid rgba(10, 159, 109, 0.12);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.demo-controls h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 10px 0 20px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
display: block;
|
||||
margin: 16px 0 8px;
|
||||
color: #506259;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: #edf3f0;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
color: #52635b;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.segmented button.is-active {
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 5px 14px rgba(10, 159, 109, 0.2);
|
||||
}
|
||||
|
||||
.bullet-list {
|
||||
margin: 16px 0 0;
|
||||
padding: 14px 14px 14px 32px;
|
||||
border-left: 3px solid var(--brand);
|
||||
border-radius: 10px;
|
||||
background: var(--brand-soft);
|
||||
color: #4c6258;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
margin: 12px 0 0;
|
||||
color: #8a9891;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.phone {
|
||||
position: relative;
|
||||
width: 390px;
|
||||
height: min(844px, calc(100vh - 36px));
|
||||
min-height: 650px;
|
||||
overflow: hidden;
|
||||
border: 8px solid #203129;
|
||||
border-radius: 34px;
|
||||
background: var(--canvas);
|
||||
box-shadow: 0 28px 70px rgba(19, 47, 36, 0.3);
|
||||
}
|
||||
|
||||
.phone::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
width: 104px;
|
||||
height: 23px;
|
||||
border-radius: 0 0 14px 14px;
|
||||
background: #203129;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.phone-scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.sticky-top {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr 52px;
|
||||
align-items: end;
|
||||
height: 66px;
|
||||
padding: 0 8px 10px;
|
||||
}
|
||||
|
||||
.mobile-nav h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0 auto;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Web panel-pill-tabs 的移动端缩比:轨道胶囊 + 激活实心绿 */
|
||||
.source-tabs {
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.source-tabs__track {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
background: #edf3f0;
|
||||
}
|
||||
|
||||
.source-tabs__btn {
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: #52635b;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.source-tabs__btn.is-active {
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 4px 12px rgba(10, 159, 109, 0.22);
|
||||
}
|
||||
|
||||
.source-tabs__btn small {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
min-width: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.source-tabs__btn:not(.is-active) small {
|
||||
background: rgba(10, 159, 109, 0.12);
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
background: #f3f7f5;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.biz-card {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 6px 20px rgb(8 127 87 / 7%);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eef4f1;
|
||||
}
|
||||
|
||||
.index-dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-dark);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.card-title strong {
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-title small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 112px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-dark);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag.is-warning {
|
||||
background: #fff6e8;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.tag.is-info {
|
||||
background: #eef2f0;
|
||||
color: #4d6a5d;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 16px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field.is-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field b {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-actions button {
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.card-actions button.is-primary {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-actions button.is-danger {
|
||||
border-color: #f3c1c1;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 20px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
right: 18px;
|
||||
bottom: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
box-shadow: 0 10px 24px rgba(10, 159, 109, 0.35);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
left: 50%;
|
||||
bottom: 88px;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(24, 51, 42, 0.92);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast.is-show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.demo-stage {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
width: min(390px, 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="demo-stage">
|
||||
<aside class="demo-controls">
|
||||
<p class="eyebrow">Mobile Preview</p>
|
||||
<h2>协议单来源双 Tab</h2>
|
||||
<p class="intro">
|
||||
对齐 Web「采集协议单」:顶部固定两个胶囊 Tab,切换后仅展示对应来源单据。细胞存储副标题展示样本编码;科研捐赠副标题展示捐赠编号。
|
||||
</p>
|
||||
|
||||
<label class="control-label">角色</label>
|
||||
<div class="segmented" id="roleSwitch">
|
||||
<button type="button" data-role="specialist" class="is-active">市场专员</button>
|
||||
<button type="button" data-role="manager">地区经理</button>
|
||||
</div>
|
||||
|
||||
<ul class="bullet-list">
|
||||
<li>两个 Tab:细胞存储 / 科研捐赠(无「全部」)</li>
|
||||
<li>切换 Tab 时列表与搜索词独立重置(示意)</li>
|
||||
<li>卡片字段沿用现有市场端列表密度</li>
|
||||
</ul>
|
||||
<p class="demo-note">静态 HTML Demo,用于确认交互与视觉后再落代码。</p>
|
||||
</aside>
|
||||
|
||||
<div class="phone" id="phone">
|
||||
<div class="phone-scroll">
|
||||
<div class="sticky-top">
|
||||
<div class="mobile-nav">
|
||||
<button type="button" class="nav-back" aria-label="返回">‹</button>
|
||||
<h1 id="pageTitle">市场专员 · 协议单</h1>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="source-tabs">
|
||||
<div class="source-tabs__track" role="tablist" aria-label="协议来源">
|
||||
<button
|
||||
type="button"
|
||||
class="source-tabs__btn is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-source="cell_storage"
|
||||
>
|
||||
细胞存储 <small id="countCell">0</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="source-tabs__btn"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-source="research_donation"
|
||||
>
|
||||
科研捐赠 <small id="countDonation">0</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<label class="search-box">
|
||||
<span>⌕</span>
|
||||
<input id="keyword" type="search" placeholder="搜索母亲姓名 / 样本编码" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list" id="list"></div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="fab" id="fab" aria-label="新增协议单">+</button>
|
||||
<div class="toast" id="toast"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DEMO = {
|
||||
cell_storage: [
|
||||
{
|
||||
id: 'c1',
|
||||
motherName: '李安然',
|
||||
sampleCode: 'ZK260411018',
|
||||
status: '生效中',
|
||||
registerDate: '2026-10-11',
|
||||
motherPhone: '138****8018',
|
||||
sampleTypeName: '脐带血',
|
||||
hospitalName: '安徽省妇幼保健院',
|
||||
salesUserName: '王敏',
|
||||
sampleCollectTime: '2026-10-11 09:20'
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
motherName: '周雨桐',
|
||||
sampleCode: 'ZK260411022',
|
||||
status: '待生效',
|
||||
registerDate: '2026-10-10',
|
||||
motherPhone: '139****6622',
|
||||
sampleTypeName: '脐带血 + 脐带',
|
||||
hospitalName: '合肥市妇幼保健院',
|
||||
salesUserName: '王敏',
|
||||
sampleCollectTime: '2026-10-10 14:05'
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
motherName: '陈思琪',
|
||||
sampleCode: 'ZK260409007',
|
||||
status: '生效中',
|
||||
registerDate: '2026-10-09',
|
||||
motherPhone: '136****9007',
|
||||
sampleTypeName: '胎盘',
|
||||
hospitalName: '安医大一附院',
|
||||
salesUserName: '刘畅',
|
||||
sampleCollectTime: '2026-10-09 11:40'
|
||||
}
|
||||
],
|
||||
research_donation: [
|
||||
{
|
||||
id: 'd1',
|
||||
motherName: '韩晓雯',
|
||||
donationNo: 'JZ20261011003',
|
||||
status: '生效中',
|
||||
donationDate: '2026-10-11',
|
||||
motherPhone: '137****3303',
|
||||
sampleTypeName: '脐带血',
|
||||
hospitalName: '安徽省立医院',
|
||||
salesUserName: '王敏',
|
||||
sampleCollectTime: '2026-10-11 16:10'
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
motherName: '许若涵',
|
||||
donationNo: 'JZ20261008011',
|
||||
status: '已作废',
|
||||
donationDate: '2026-10-08',
|
||||
motherPhone: '158****8811',
|
||||
sampleTypeName: '脐带',
|
||||
hospitalName: '合肥市妇幼保健院',
|
||||
salesUserName: '刘畅',
|
||||
sampleCollectTime: '2026-10-08 08:50'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const state = {
|
||||
role: 'specialist',
|
||||
source: 'cell_storage',
|
||||
keyword: ''
|
||||
}
|
||||
|
||||
const roleTitles = { specialist: '市场专员', manager: '地区经理' }
|
||||
const listEl = document.getElementById('list')
|
||||
const toastEl = document.getElementById('toast')
|
||||
const keywordEl = document.getElementById('keyword')
|
||||
let toastTimer = null
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
toastEl.textContent = message
|
||||
toastEl.classList.add('is-show')
|
||||
window.clearTimeout(toastTimer)
|
||||
toastTimer = window.setTimeout(() => toastEl.classList.remove('is-show'), 1600)
|
||||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (/作废|拒绝/.test(status)) return 'is-warning'
|
||||
if (/待/.test(status)) return 'is-info'
|
||||
return ''
|
||||
}
|
||||
|
||||
function filteredRows() {
|
||||
const rows = DEMO[state.source] || []
|
||||
const keyword = state.keyword.trim().toLowerCase()
|
||||
if (!keyword) return rows
|
||||
return rows.filter((row) => JSON.stringify(row).toLowerCase().includes(keyword))
|
||||
}
|
||||
|
||||
function field(label, value, wide) {
|
||||
return `<div class="field${wide ? ' is-wide' : ''}"><span>${esc(label)}</span><b>${value}</b></div>`
|
||||
}
|
||||
|
||||
function renderCard(row, index) {
|
||||
const isCell = state.source === 'cell_storage'
|
||||
const subtitle = isCell
|
||||
? `细胞存储 · ${row.sampleCode || '—'}`
|
||||
: `科研捐赠 · ${row.donationNo || '—'}`
|
||||
const dateLabel = isCell ? '登记日期' : '捐赠日期'
|
||||
const dateValue = isCell ? row.registerDate : row.donationDate
|
||||
const actions = `
|
||||
<button type="button" class="is-primary" data-action="view">查看</button>
|
||||
<button type="button" data-action="edit">编辑</button>
|
||||
${state.role === 'manager' ? '<button type="button" data-action="audit">审核</button>' : ''}
|
||||
<button type="button" class="is-danger" data-action="delete">删除</button>
|
||||
`
|
||||
return `
|
||||
<article class="biz-card" data-id="${esc(row.id)}">
|
||||
<div class="card-head">
|
||||
<span class="index-dot">${index + 1}</span>
|
||||
<div class="card-title">
|
||||
<strong>${esc(row.motherName)}</strong>
|
||||
<small>${esc(subtitle)}</small>
|
||||
</div>
|
||||
<span class="tag ${statusTone(row.status)}">${esc(row.status)}</span>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
${field('状态', `<span class="tag ${statusTone(row.status)}">${esc(row.status)}</span>`)}
|
||||
${field(dateLabel, esc(dateValue))}
|
||||
${field('母亲姓名', esc(row.motherName))}
|
||||
${field('母亲电话', esc(row.motherPhone))}
|
||||
${field(isCell ? '样本编码' : '捐赠编号', esc(isCell ? row.sampleCode : row.donationNo), true)}
|
||||
${field('样本类型', esc(row.sampleTypeName), true)}
|
||||
${field('采集医院', esc(row.hospitalName), true)}
|
||||
${field('市场专员', esc(row.salesUserName))}
|
||||
${field('样本采集时间', esc(row.sampleCollectTime), true)}
|
||||
</div>
|
||||
<div class="card-actions">${actions}</div>
|
||||
</article>
|
||||
`
|
||||
}
|
||||
|
||||
function updatePlaceholders() {
|
||||
keywordEl.placeholder =
|
||||
state.source === 'cell_storage'
|
||||
? '搜索母亲姓名 / 样本编码'
|
||||
: '搜索母亲姓名 / 捐赠编号 / 样本编码'
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById('pageTitle').textContent = `${roleTitles[state.role]} · 协议单`
|
||||
document.getElementById('countCell').textContent = String(DEMO.cell_storage.length)
|
||||
document.getElementById('countDonation').textContent = String(DEMO.research_donation.length)
|
||||
|
||||
document.querySelectorAll('.source-tabs__btn').forEach((btn) => {
|
||||
const active = btn.dataset.source === state.source
|
||||
btn.classList.toggle('is-active', active)
|
||||
btn.setAttribute('aria-selected', active ? 'true' : 'false')
|
||||
})
|
||||
|
||||
updatePlaceholders()
|
||||
const rows = filteredRows()
|
||||
listEl.innerHTML = rows.length
|
||||
? rows.map((row, index) => renderCard(row, index)).join('')
|
||||
: '<div class="empty">该来源下暂无协议单</div>'
|
||||
}
|
||||
|
||||
document.getElementById('roleSwitch').addEventListener('click', (event) => {
|
||||
const btn = event.target.closest('button[data-role]')
|
||||
if (!btn) return
|
||||
state.role = btn.dataset.role
|
||||
document.querySelectorAll('#roleSwitch button').forEach((item) => {
|
||||
item.classList.toggle('is-active', item === btn)
|
||||
})
|
||||
render()
|
||||
})
|
||||
|
||||
document.querySelector('.source-tabs__track').addEventListener('click', (event) => {
|
||||
const btn = event.target.closest('button[data-source]')
|
||||
if (!btn || btn.dataset.source === state.source) return
|
||||
state.source = btn.dataset.source
|
||||
state.keyword = ''
|
||||
keywordEl.value = ''
|
||||
render()
|
||||
showToast(state.source === 'cell_storage' ? '已切换到细胞存储' : '已切换到科研捐赠')
|
||||
})
|
||||
|
||||
keywordEl.addEventListener('input', () => {
|
||||
state.keyword = keywordEl.value
|
||||
render()
|
||||
})
|
||||
|
||||
listEl.addEventListener('click', (event) => {
|
||||
const btn = event.target.closest('button[data-action]')
|
||||
if (!btn) return
|
||||
const card = btn.closest('.biz-card')
|
||||
showToast(`${btn.textContent} · ${card?.querySelector('strong')?.textContent || ''}`)
|
||||
})
|
||||
|
||||
document.getElementById('fab').addEventListener('click', () => {
|
||||
showToast(
|
||||
state.source === 'cell_storage' ? '新增细胞存储协议单' : '新增科研捐赠协议单'
|
||||
)
|
||||
})
|
||||
|
||||
render()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -172,13 +172,9 @@ INNER JOIN (
|
||||
'/pages/market/webview/index?entry=specialist-payment',
|
||||
'mini:market:specialist:payment', 40
|
||||
UNION ALL
|
||||
SELECT 'specialist', '市场专员', 'sample', '样本信息',
|
||||
'/pages/market/webview/index?entry=specialist-sample',
|
||||
'mini:market:specialist:sample', 50
|
||||
UNION ALL
|
||||
SELECT 'specialist', '市场专员', 'freight', '运费信息',
|
||||
'/pages/market/webview/index?entry=specialist-freight',
|
||||
'mini:market:specialist:freight', 60
|
||||
'mini:market:specialist:freight', 50
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'presign', '预签约订单',
|
||||
'/pages/market/webview/index?entry=manager-presign',
|
||||
@@ -196,13 +192,9 @@ INNER JOIN (
|
||||
'/pages/market/webview/index?entry=manager-payment',
|
||||
'mini:market:manager:payment', 40
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'sample', '样本信息',
|
||||
'/pages/market/webview/index?entry=manager-sample',
|
||||
'mini:market:manager:sample', 50
|
||||
UNION ALL
|
||||
SELECT 'manager', '地区经理', 'freight', '运费信息',
|
||||
'/pages/market/webview/index?entry=manager-freight',
|
||||
'mini:market:manager:freight', 60
|
||||
'mini:market:manager:freight', 50
|
||||
) entry
|
||||
ON 1 = 1
|
||||
INNER JOIN sys_menu directory
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
-- =============================================================================
|
||||
-- 移动端菜单重组:市场部 / OA工作流两大目录
|
||||
--
|
||||
-- 目标结构:
|
||||
-- 移动端菜单
|
||||
-- ├─ 市场部
|
||||
-- │ ├─ 预签约订单(专员)…运费信息(专员)
|
||||
-- │ └─ 预签约订单(经理)…运费信息(经理)
|
||||
-- └─ OA工作流
|
||||
-- └─ 发起审批…开启微信通知
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 复用原「市场专员」目录 id,改名为「市场部」;原「地区经理」目录软删。
|
||||
-- 2. 叶子 id / path / perms 不变,仅调整 parent_id、menu_name、order_num。
|
||||
-- 3. 已授权 OA 叶子的角色自动补授「OA工作流」目录(幂等)。
|
||||
-- 4. MySQL 5.7 可执行。
|
||||
-- =============================================================================
|
||||
|
||||
-- 1) 「市场专员」→「市场部」
|
||||
UPDATE sys_menu market_dir
|
||||
INNER JOIN sys_menu root
|
||||
ON root.id = market_dir.parent_id
|
||||
AND root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
SET
|
||||
market_dir.menu_name = '市场部',
|
||||
market_dir.order_num = 10,
|
||||
market_dir.remark = '小程序:市场部菜单目录',
|
||||
market_dir.update_time = NOW()
|
||||
WHERE market_dir.menu_type = 'M'
|
||||
AND market_dir.path = ''
|
||||
AND market_dir.del_flag = 0
|
||||
AND market_dir.menu_name IN ('市场专员', '市场部');
|
||||
|
||||
-- 2) 新建「OA工作流」目录(每个租户移动端根下一条)
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, 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
|
||||
MD5(CONCAT(
|
||||
'mobile-oa-directory|', root.tenant_no, '|', root.id, '|M||OA工作流'
|
||||
)),
|
||||
'OA工作流',
|
||||
root.id,
|
||||
'M',
|
||||
'',
|
||||
NULL,
|
||||
NULL,
|
||||
1,
|
||||
'1',
|
||||
'1',
|
||||
root.tenant_no,
|
||||
20,
|
||||
'小程序:OA工作流菜单目录',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_menu root
|
||||
WHERE root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_menu existing
|
||||
WHERE existing.tenant_no = root.tenant_no
|
||||
AND existing.parent_id = root.id
|
||||
AND existing.menu_name = 'OA工作流'
|
||||
AND existing.menu_type = 'M'
|
||||
AND existing.path = ''
|
||||
AND existing.del_flag = 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_no = VALUES(tenant_no),
|
||||
parent_id = VALUES(parent_id),
|
||||
menu_name = VALUES(menu_name),
|
||||
menu_type = VALUES(menu_type),
|
||||
path = VALUES(path),
|
||||
order_num = VALUES(order_num),
|
||||
remark = VALUES(remark),
|
||||
update_time = VALUES(update_time),
|
||||
del_flag = VALUES(del_flag);
|
||||
|
||||
-- 3) 市场叶子:挂到「市场部」,名称加(专员)/(经理),调整排序
|
||||
UPDATE sys_menu leaf
|
||||
INNER JOIN sys_menu root
|
||||
ON root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND root.tenant_no = leaf.tenant_no
|
||||
INNER JOIN sys_menu market_dir
|
||||
ON market_dir.tenant_no = root.tenant_no
|
||||
AND market_dir.parent_id = root.id
|
||||
AND market_dir.menu_name = '市场部'
|
||||
AND market_dir.menu_type = 'M'
|
||||
AND market_dir.path = ''
|
||||
AND market_dir.del_flag = 0
|
||||
INNER JOIN (
|
||||
SELECT 'mini:market:specialist:presign' AS perms, '预签约订单(专员)' AS menu_name, 10 AS order_num
|
||||
UNION ALL SELECT 'mini:market:specialist:protocol', '协议单(专员)', 20
|
||||
UNION ALL SELECT 'mini:market:specialist:payment-chase', '催缴信息(专员)', 30
|
||||
UNION ALL SELECT 'mini:market:specialist:payment', '缴费信息(专员)', 40
|
||||
UNION ALL SELECT 'mini:market:specialist:freight', '运费信息(专员)', 50
|
||||
UNION ALL SELECT 'mini:market:manager:presign', '预签约订单(经理)', 60
|
||||
UNION ALL SELECT 'mini:market:manager:protocol', '协议单(经理)', 70
|
||||
UNION ALL SELECT 'mini:market:manager:payment-chase', '催缴信息(经理)', 80
|
||||
UNION ALL SELECT 'mini:market:manager:payment', '缴费信息(经理)', 90
|
||||
UNION ALL SELECT 'mini:market:manager:freight', '运费信息(经理)', 100
|
||||
) map ON map.perms = leaf.perms
|
||||
SET
|
||||
leaf.parent_id = market_dir.id,
|
||||
leaf.menu_name = map.menu_name,
|
||||
leaf.order_num = map.order_num,
|
||||
leaf.remark = CONCAT('小程序市场现场:', map.menu_name),
|
||||
leaf.update_time = NOW()
|
||||
WHERE leaf.menu_type = 'C'
|
||||
AND leaf.del_flag = 0
|
||||
AND leaf.perms LIKE 'mini:market:%';
|
||||
|
||||
-- 4) OA 叶子:挂到「OA工作流」
|
||||
UPDATE sys_menu leaf
|
||||
INNER JOIN sys_menu root
|
||||
ON root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
AND root.tenant_no = leaf.tenant_no
|
||||
INNER JOIN sys_menu oa_dir
|
||||
ON oa_dir.tenant_no = root.tenant_no
|
||||
AND oa_dir.parent_id = root.id
|
||||
AND oa_dir.menu_name = 'OA工作流'
|
||||
AND oa_dir.menu_type = 'M'
|
||||
AND oa_dir.path = ''
|
||||
AND oa_dir.del_flag = 0
|
||||
SET
|
||||
leaf.parent_id = oa_dir.id,
|
||||
leaf.update_time = NOW()
|
||||
WHERE leaf.del_flag = 0
|
||||
AND leaf.menu_type = 'C'
|
||||
AND (
|
||||
leaf.perms IN (
|
||||
'mini:oa:start',
|
||||
'mini:oa:drafts',
|
||||
'mini:oa:pending',
|
||||
'mini:oa:done',
|
||||
'mini:oa:mine',
|
||||
'mini:oa:cc',
|
||||
'oa:mention',
|
||||
'mini:oa:wechat-notify'
|
||||
)
|
||||
OR leaf.path IN (
|
||||
'/pages/oa/start/index',
|
||||
'/pages/oa/drafts/index',
|
||||
'/pages/oa/pending/index',
|
||||
'/pages/oa/done/index',
|
||||
'/pages/oa/mine/index',
|
||||
'/pages/oa/cc/index',
|
||||
'/pages/oa/mentions/index',
|
||||
'/pages/oa/wechat-notify/index'
|
||||
)
|
||||
);
|
||||
|
||||
-- 5) 软删「地区经理」目录(步骤 3 已把子节点迁到市场部;避免同表子查询触发 1093)
|
||||
UPDATE sys_menu manager_dir
|
||||
INNER JOIN sys_menu root
|
||||
ON root.id = manager_dir.parent_id
|
||||
AND root.parent_id = '0'
|
||||
AND root.menu_name = '移动端菜单'
|
||||
AND root.del_flag = 0
|
||||
SET
|
||||
manager_dir.del_flag = 1,
|
||||
manager_dir.update_time = NOW(),
|
||||
manager_dir.remark = '已重组至市场部,软删'
|
||||
WHERE manager_dir.menu_type = 'M'
|
||||
AND manager_dir.path = ''
|
||||
AND manager_dir.menu_name = '地区经理'
|
||||
AND manager_dir.del_flag = 0;
|
||||
|
||||
-- 6) 已有 OA 叶子授权的角色补授「OA工作流」目录
|
||||
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
|
||||
MD5(CONCAT(r.id, '|', oa_dir.id)),
|
||||
r.id,
|
||||
oa_dir.id,
|
||||
IFNULL(NULLIF(r.tenant_no, ''), oa_dir.tenant_no),
|
||||
0,
|
||||
'grant OA工作流 directory to roles with OA leaf menus',
|
||||
NULL,
|
||||
'',
|
||||
NOW(),
|
||||
NULL,
|
||||
NOW(),
|
||||
0
|
||||
FROM sys_role r
|
||||
INNER JOIN sys_role_menu rm
|
||||
ON rm.role_id = r.id
|
||||
AND rm.del_flag = 0
|
||||
INNER JOIN sys_menu leaf
|
||||
ON leaf.id = rm.menu_id
|
||||
AND leaf.del_flag = 0
|
||||
AND leaf.menu_type = 'C'
|
||||
AND (
|
||||
leaf.perms IN (
|
||||
'mini:oa:start',
|
||||
'mini:oa:drafts',
|
||||
'mini:oa:pending',
|
||||
'mini:oa:done',
|
||||
'mini:oa:mine',
|
||||
'mini:oa:cc',
|
||||
'oa:mention',
|
||||
'mini:oa:wechat-notify'
|
||||
)
|
||||
OR leaf.path LIKE '/pages/oa/%'
|
||||
)
|
||||
INNER JOIN sys_menu oa_dir
|
||||
ON oa_dir.id = leaf.parent_id
|
||||
AND oa_dir.menu_name = 'OA工作流'
|
||||
AND oa_dir.menu_type = 'M'
|
||||
AND oa_dir.del_flag = 0
|
||||
WHERE r.del_flag = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_role_menu x
|
||||
WHERE x.del_flag = 0
|
||||
AND x.role_id = r.id
|
||||
AND x.menu_id = oa_dir.id
|
||||
)
|
||||
GROUP BY r.id, oa_dir.id, r.tenant_no, oa_dir.tenant_no;
|
||||
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS biz_sample_customer_service (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bscs_tenant_sample (tenant_no, sample_code),
|
||||
KEY idx_bscs_protocol (tenant_no, collection_protocol_id, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本客户服务扩展';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本客户服务扩展';
|
||||
-- 注:uk_bscs_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_customer_service_dict (
|
||||
@@ -45,7 +45,7 @@ CREATE TABLE IF NOT EXISTS biz_customer_service_dict (
|
||||
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bcsd_tenant_type_value (tenant_no, dict_type, dict_value)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户服务字典';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户服务字典';
|
||||
-- 注:uk_bcsd_tenant_type_value 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
|
||||
|
||||
-- 预置字典种子(tenant_no=zkxw,is_system=1;幂等 NOT EXISTS)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- =============================================================================
|
||||
-- 下线小程序「市场专员 / 地区经理 → 样本信息」菜单(2026-10-13)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- 样本信息(客服管理)不面向市场专员、地区经理;移动端入口与 OA H5 路由已移除。
|
||||
-- 本脚本将已入库的叶子菜单设为不可见(权限码保留,避免角色菜单残留脏数据时可另行清理)。
|
||||
--
|
||||
-- perms:
|
||||
-- mini:market:specialist:sample
|
||||
-- mini:market:manager:sample
|
||||
-- visible: '0'=隐藏
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET visible = '0',
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
perms IN (
|
||||
'mini:market:specialist:sample',
|
||||
'mini:market:manager:sample'
|
||||
)
|
||||
OR path IN (
|
||||
'/pages/market/webview/index?entry=specialist-sample',
|
||||
'/pages/market/webview/index?entry=manager-sample'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- =============================================================================
|
||||
-- 菜单改名:客户管理 → 客户基础信息
|
||||
-- id: 121
|
||||
-- perms: biz:customer:list
|
||||
-- 父级:市场基础信息
|
||||
--
|
||||
-- 【前端固定菜单】同步修改:
|
||||
-- - web-admin/src/router/modules/sqxp.ts
|
||||
-- - backend/app/api/menu.py
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE sys_menu
|
||||
SET menu_name = '客户基础信息',
|
||||
update_time = NOW()
|
||||
WHERE del_flag = 0
|
||||
AND (
|
||||
id = '121'
|
||||
OR perms = 'biz:customer:list'
|
||||
)
|
||||
AND menu_type = 'C'
|
||||
AND menu_name <> '客户基础信息';
|
||||
@@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS biz_sample_storage_cert (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bssc_tenant_sample (tenant_no, sample_code),
|
||||
KEY idx_bssc_protocol (tenant_no, collection_protocol_id, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本储存证办理';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本储存证办理';
|
||||
-- 注:uk_bssc_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_sample_insurance (
|
||||
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS biz_sample_insurance (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_bsi_tenant_sample (tenant_no, sample_code),
|
||||
KEY idx_bsi_protocol (tenant_no, collection_protocol_id, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本保险办理';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本保险办理';
|
||||
-- 注:uk_bsi_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。
|
||||
|
||||
-- 迁移:协议级储存证 → 协议下每个未删样本一行(幂等 NOT EXISTS)
|
||||
@@ -69,7 +69,8 @@ INSERT INTO biz_sample_storage_cert (
|
||||
update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
REPLACE(UUID(), '-', ''),
|
||||
-- MySQL 5.7 同语句批量 UUID() 可能撞主键,用业务键派生稳定唯一 id
|
||||
MD5(CONCAT('bssc#', c.tenant_no, '#', ps.sample_code, '#', c.id, '#', ps.id)),
|
||||
ps.sample_code,
|
||||
c.collection_protocol_id,
|
||||
c.make_status,
|
||||
@@ -89,16 +90,16 @@ SELECT
|
||||
0
|
||||
FROM biz_protocol_storage_cert c
|
||||
INNER JOIN biz_protocol_sample ps
|
||||
ON ps.collection_protocol_id = c.collection_protocol_id
|
||||
AND ps.tenant_no = c.tenant_no
|
||||
ON (ps.collection_protocol_id COLLATE utf8mb4_unicode_ci) = (c.collection_protocol_id COLLATE utf8mb4_unicode_ci)
|
||||
AND (ps.tenant_no COLLATE utf8mb4_unicode_ci) = (c.tenant_no COLLATE utf8mb4_unicode_ci)
|
||||
AND ps.del_flag = 0
|
||||
WHERE c.del_flag = 0
|
||||
AND ps.sample_code IS NOT NULL
|
||||
AND ps.sample_code <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM biz_sample_storage_cert t
|
||||
WHERE t.tenant_no = c.tenant_no
|
||||
AND t.sample_code = ps.sample_code
|
||||
WHERE (t.tenant_no COLLATE utf8mb4_unicode_ci) = (c.tenant_no COLLATE utf8mb4_unicode_ci)
|
||||
AND (t.sample_code COLLATE utf8mb4_unicode_ci) = (ps.sample_code COLLATE utf8mb4_unicode_ci)
|
||||
AND t.del_flag = 0
|
||||
);
|
||||
|
||||
@@ -114,7 +115,7 @@ INSERT INTO biz_sample_insurance (
|
||||
update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
REPLACE(UUID(), '-', ''),
|
||||
MD5(CONCAT('bsi#', t.tenant_no, '#', ps.sample_code, '#', t.id, '#', ps.id)),
|
||||
ps.sample_code,
|
||||
t.collection_protocol_id,
|
||||
t.todo_status,
|
||||
@@ -143,15 +144,15 @@ SELECT
|
||||
0
|
||||
FROM biz_protocol_fullmoon_todo t
|
||||
INNER JOIN biz_protocol_sample ps
|
||||
ON ps.collection_protocol_id = t.collection_protocol_id
|
||||
AND ps.tenant_no = t.tenant_no
|
||||
ON (ps.collection_protocol_id COLLATE utf8mb4_unicode_ci) = (t.collection_protocol_id COLLATE utf8mb4_unicode_ci)
|
||||
AND (ps.tenant_no COLLATE utf8mb4_unicode_ci) = (t.tenant_no COLLATE utf8mb4_unicode_ci)
|
||||
AND ps.del_flag = 0
|
||||
WHERE t.del_flag = 0
|
||||
AND ps.sample_code IS NOT NULL
|
||||
AND ps.sample_code <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM biz_sample_insurance s
|
||||
WHERE s.tenant_no = t.tenant_no
|
||||
AND s.sample_code = ps.sample_code
|
||||
WHERE (s.tenant_no COLLATE utf8mb4_unicode_ci) = (t.tenant_no COLLATE utf8mb4_unicode_ci)
|
||||
AND (s.sample_code COLLATE utf8mb4_unicode_ci) = (ps.sample_code COLLATE utf8mb4_unicode_ci)
|
||||
AND s.del_flag = 0
|
||||
);
|
||||
@@ -0,0 +1,549 @@
|
||||
# 小程序市场专员 / 地区经理存储业务 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:** 在微信小程序中提供市场专员与地区经理两套独立的存储业务入口,由 `mobile-oa-h5` 承载六个业务模块,并由后端对租户、菜单权限、通用数据权限及现场 scope 做全链路强制校验。
|
||||
|
||||
**Architecture:** `mobile-h5` 只负责授权菜单与受控 `web-view`;`mobile-oa-h5` 使用十二个静态入口路由和共享移动业务组件;后端新增 `/api/mini/market/{specialist|manager}` 专用路由和集中 scope 服务。现有 Web CRUD 作为领域能力来源,但移动端通过白名单 DTO/动作调用,禁止直接暴露财务、审核、生产与质检能力。
|
||||
|
||||
**Tech Stack:** FastAPI、SQLAlchemy、Pydantic v2、Pytest、Vue 3、Vue Router、Vant、uni-app、Node `node:test`、MySQL 5.7。
|
||||
|
||||
---
|
||||
|
||||
## 任务地图
|
||||
|
||||
| 任务 | 交付物 | 主要验证 |
|
||||
|---|---|---|
|
||||
| 1 | 严格移动菜单权限与租户化菜单 | `test_market_mobile_permissions.py`、`test_mini_menu.py` |
|
||||
| 2 | 集中 specialist/manager scope 服务 | `test_market_field_scope.py` |
|
||||
| 3 | 预签移动 API | `test_market_presign_api.py` |
|
||||
| 4 | 协议(细胞/科研)移动 API | `test_market_protocol_api.py` |
|
||||
| 5 | 催缴/缴费移动 API | `test_market_payment_api.py` |
|
||||
| 6 | 样本轻量移动 API | `test_market_sample_api.py` |
|
||||
| 7 | 运费脱敏移动 API | `test_market_freight_scope.py` |
|
||||
| 8 | 菜单 SQL 与小程序受控 web-view | Node 测试、`test_mini_menu.py` |
|
||||
| 9 | H5 路由、公共组件与只读页面 | Node 路由测试、Vite build |
|
||||
| 10 | 六模块写页面与动作 | Node 单测、Vite build、API 回归 |
|
||||
| 11 | 全链路回归、知识图谱与总结文档 | Pytest、Node、build、字段一致性、图谱 |
|
||||
|
||||
### Task 1: 严格移动菜单权限与租户化菜单
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/core/mobile_menu_guard.py`
|
||||
- Create: `backend/tests/test_market_mobile_permissions.py`
|
||||
- Create: `backend/tests/test_mini_menu.py`
|
||||
- Modify: `backend/app/api/mini_menu.py`
|
||||
- Modify: `backend/app/services/web_auth_roles.py`
|
||||
|
||||
- [x] **Step 1: 写严格权限与租户隔离失败测试**
|
||||
|
||||
```python
|
||||
def test_specialist_permission_does_not_grant_manager_access():
|
||||
assert require_market_menu_permission(
|
||||
user_id="u1", tenant_no="t1", required="mini:market:manager:presign",
|
||||
granted={"mini:market:specialist:presign"},
|
||||
) is False
|
||||
|
||||
def test_mobile_menu_query_uses_current_tenant():
|
||||
items = list_mobile_menu_items(db, user_id="u1", tenant_no="tenant-b")
|
||||
assert {item["id"] for item in items} == {"tenant-b-specialist-presign"}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试并确认因实现缺失失败**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_mobile_permissions.py tests/test_mini_menu.py -q`
|
||||
|
||||
Expected: FAIL,提示 `mobile_menu_guard` 或租户化参数不存在。
|
||||
|
||||
- [x] **Step 3: 实现严格权限依赖**
|
||||
|
||||
`mobile_menu_guard.py` 提供:
|
||||
|
||||
```python
|
||||
def require_market_menu_code(menu_code: str):
|
||||
async def dependency(user=Depends(get_current_user), db=Depends(get_db)):
|
||||
tenant_no = str(user.get("tenant_no") or "").strip()
|
||||
user_id = str(user.get("user_id") or "").strip()
|
||||
if not tenant_no or not user_id:
|
||||
raise HTTPException(status_code=403, detail="用户租户或身份信息缺失")
|
||||
if user.get("role") != "super_admin" and not user_has_tenant_menu_perm(
|
||||
db, user_id=user_id, tenant_no=tenant_no, perm_code=menu_code
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="无移动端菜单权限")
|
||||
return user
|
||||
return dependency
|
||||
```
|
||||
|
||||
把 `get_effective_role_ids()`、`iter_user_menu_perm_codes()` 扩展为显式接收 `tenant_no`,兼容原调用方但新移动链路禁止回退 `DEFAULT_TENANT_NO`。`mini_menu.py` 从认证结果读取当前租户,并返回直属目录的 `groupTitle`;目录无 path 时不输出。
|
||||
|
||||
- [x] **Step 4: 验证权限、职位继承角色和跨租户菜单**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_mobile_permissions.py tests/test_mini_menu.py tests/test_effective_roles.py -q`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 2: 集中 specialist/manager 现场 scope 服务
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/market_field_scope.py`
|
||||
- Create: `backend/tests/test_market_field_scope.py`
|
||||
- Modify: `backend/app/core/data_scope.py`(仅在需要提供可组合 predicate 时修改)
|
||||
|
||||
- [x] **Step 1: 写 scope 失败测试**
|
||||
|
||||
覆盖以下最小事实:
|
||||
|
||||
```python
|
||||
def test_specialist_matches_sales_user_only(): ...
|
||||
def test_manager_matches_leader_region_or_document_manager(): ...
|
||||
def test_manager_ignores_non_leader_staff_rows(): ...
|
||||
def test_soft_deleted_or_cross_tenant_region_never_matches(): ...
|
||||
def test_dual_role_contexts_do_not_union_specialist_and_manager_scope(): ...
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_field_scope.py -q`
|
||||
|
||||
Expected: FAIL,服务尚不存在。
|
||||
|
||||
- [x] **Step 3: 实现不可变上下文和查询组合器**
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class MarketFieldContext:
|
||||
tenant_no: str
|
||||
user_id: str
|
||||
scope_kind: Literal["specialist", "manager"]
|
||||
|
||||
def leader_region_ids_query(db: Session, ctx: MarketFieldContext): ...
|
||||
def apply_presign_scope(query, db: Session, ctx: MarketFieldContext): ...
|
||||
def apply_cell_protocol_scope(query, db: Session, ctx: MarketFieldContext): ...
|
||||
def apply_donation_scope(query, db: Session, ctx: MarketFieldContext): ...
|
||||
def assert_presign_access(...): ...
|
||||
def assert_cell_protocol_access(...): ...
|
||||
def assert_donation_access(...): ...
|
||||
```
|
||||
|
||||
所有 predicate 必须包含 `tenant_no` 与 `del_flag=0`;manager 条件必须是 `leader region OR region_manager_user_id=current user`。服务同时提供将通用 `apply_data_scope()` 与现场 scope 组合的入口。
|
||||
|
||||
- [x] **Step 4: 验证 scope 单测**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_field_scope.py tests/test_basic_info_tenant_ownership.py -q`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 3: 预签移动 API
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/market_mobile.py`
|
||||
- Create: `backend/app/schemas/market_mobile.py`
|
||||
- Create: `backend/app/services/market_presign.py`
|
||||
- Create: `backend/tests/test_market_presign_api.py`
|
||||
- Modify: `backend/app/api/__init__.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
- Reuse: `backend/app/crud/presign_order.py`
|
||||
|
||||
- [x] **Step 1: 写列表、详情和 IDOR 失败测试**
|
||||
|
||||
```python
|
||||
def test_specialist_presign_list_only_returns_own_sales_orders(): ...
|
||||
def test_manager_presign_list_uses_region_or_manager_union(): ...
|
||||
def test_presign_detail_rejects_guessed_out_of_scope_id(): ...
|
||||
def test_specialist_create_forces_current_sales_user(): ...
|
||||
def test_update_rejects_resulting_assignment_outside_scope(): ...
|
||||
def test_convert_rechecks_scope_and_keeps_expected_staff_mapping(): ...
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_presign_api.py -q`
|
||||
|
||||
Expected: FAIL,移动路由不存在。
|
||||
|
||||
- [x] **Step 3: 实现两套固定路由和白名单 DTO**
|
||||
|
||||
路由形态:
|
||||
|
||||
```text
|
||||
GET/POST /api/mini/market/specialist/presign-orders
|
||||
GET/PUT /api/mini/market/specialist/presign-orders/{id}
|
||||
POST /api/mini/market/specialist/presign-orders/{id}/convert
|
||||
GET/POST /api/mini/market/manager/presign-orders
|
||||
GET/PUT /api/mini/market/manager/presign-orders/{id}
|
||||
POST /api/mini/market/manager/presign-orders/{id}/convert
|
||||
```
|
||||
|
||||
共享 handler 只接收由路由闭包注入的 scope。专员创建强制 `sales_user_id=current user`;经理创建/修改后的 `region_id/region_manager_user_id` 必须仍命中 manager scope。状态只读,不开放删除和财务确认动作。
|
||||
|
||||
- [x] **Step 4: 验证预签与既有回归**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_presign_api.py tests/test_presign_order.py -q`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 4: 协议(细胞存储 / 科研捐赠)移动 API
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/market_protocol.py`
|
||||
- Create: `backend/tests/test_market_protocol_api.py`
|
||||
- Modify: `backend/app/api/market_mobile.py`
|
||||
- Modify: `backend/app/schemas/market_mobile.py`
|
||||
- Reuse: `backend/app/crud/collection_protocol.py`
|
||||
- Reuse: `backend/app/crud/research_donation.py`
|
||||
|
||||
- [x] **Step 1: 写双来源列表、详情、创建更新失败测试**
|
||||
|
||||
```python
|
||||
def test_protocol_list_combines_cell_and_donation_without_scope_leak(): ...
|
||||
def test_cell_protocol_detail_rechecks_specialist_scope(): ...
|
||||
def test_donation_detail_rechecks_manager_scope(): ...
|
||||
def test_specialist_create_forces_sales_user_in_collection_info(): ...
|
||||
def test_manager_update_cannot_move_document_out_of_scope(): ...
|
||||
def test_mobile_protocol_api_does_not_expose_audit_void_refund_or_conversion(): ...
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_protocol_api.py -q`
|
||||
|
||||
Expected: FAIL,协议移动服务不存在。
|
||||
|
||||
- [x] **Step 3: 实现统一移动 DTO 与两来源适配器**
|
||||
|
||||
提供 `source=cell_storage|research_donation`,但 scope 固定由路由决定。列表返回移动卡片字段;详情返回基本、采集、客户、样本、缴费(仅细胞)、附件分区。写入只允许设计内字段;拒绝 `auditStatus/protocolStatus/delFlag/refund/finance*` 等越权字段。专员采集信息强制当前用户,经理写入结果须仍在 manager scope。
|
||||
|
||||
- [x] **Step 4: 验证协议回归**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_protocol_api.py tests/test_collection_protocol_save_bundle.py tests/test_collection_protocol_conventions.py -q`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 5: 催缴与缴费移动 API
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/market_payment.py`
|
||||
- Create: `backend/tests/test_market_payment_api.py`
|
||||
- Modify: `backend/app/api/market_mobile.py`
|
||||
- Modify: `backend/app/schemas/market_mobile.py`
|
||||
- Reuse: `backend/app/crud/protocol_sample_payment.py`
|
||||
- Reuse: `backend/app/services/payment_chase.py`
|
||||
|
||||
- [x] **Step 1: 写催缴/缴费范围与动作白名单失败测试**
|
||||
|
||||
```python
|
||||
def test_chase_is_overdue_union_next_30_days_with_parent_scope(): ...
|
||||
def test_client_sales_user_filter_cannot_expand_scope(): ...
|
||||
def test_payment_detail_rejects_out_of_scope_sample(): ...
|
||||
def test_market_can_save_submit_and_withdraw_transfer(): ...
|
||||
def test_market_cannot_confirm_reject_invoice_or_refund(): ...
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_payment_api.py -q`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
- [x] **Step 3: 实现父协议 scope 下的有限缴费动作**
|
||||
|
||||
催缴只提供查询与跳转标识,不创建“催缴编辑实体”。缴费允许查看、登记转账草稿、提交财务确认、确认中撤回;财务确认/拒绝、退款确认和开票路由不存在。任何 `salesUserId/fieldScope/tenantNo` 客户端参数不得改变授权范围。
|
||||
|
||||
- [x] **Step 4: 验证缴费回归**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_payment_api.py tests/test_payment_chase.py tests/test_protocol_sample_payment.py tests/test_payment_transfer_delete.py -q`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 6: 样本轻量移动 API
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/market_sample.py`
|
||||
- Create: `backend/tests/test_market_sample_api.py`
|
||||
- Modify: `backend/app/api/market_mobile.py`
|
||||
- Modify: `backend/app/schemas/market_mobile.py`
|
||||
- Reuse: `backend/app/crud/protocol_sample_collection.py`
|
||||
- Reuse: `backend/app/crud/research_donation_sample_collection.py`
|
||||
- Reuse: `backend/app/services/sample_info_list_enrich.py`
|
||||
|
||||
- [x] **Step 1: 写双来源样本与字段白名单失败测试**
|
||||
|
||||
```python
|
||||
def test_sample_list_resolves_parent_and_applies_field_scope(): ...
|
||||
def test_sample_detail_uses_tenant_and_sample_code_for_downstream_match(): ...
|
||||
def test_lightweight_update_accepts_market_fields_only(): ...
|
||||
def test_lightweight_update_rejects_qc_production_lab_code_and_status(): ...
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_sample_api.py -q`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
- [x] **Step 3: 实现只读进度与轻量办理白名单**
|
||||
|
||||
列表/详情统一返回细胞与科研来源、样本编码、采集/接收/质检/入库进度。跨表匹配遵循 `tenant_no + sample_code`。写 DTO 只开放市场侧时间、人员、附件和备注等已确认轻量字段;禁止修改质检结论、生产结果、实验室码和样本状态。
|
||||
|
||||
- [x] **Step 4: 验证样本回归**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_sample_api.py tests/test_sample_info_progress.py tests/test_sample_info_handle.py -q`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 7: 运费批次可见性、明细脱敏与写校验
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/market_freight.py`
|
||||
- Create: `backend/tests/test_market_freight_scope.py`
|
||||
- Modify: `backend/app/api/market_mobile.py`
|
||||
- Modify: `backend/app/schemas/market_mobile.py`
|
||||
- Reuse: `backend/app/crud/sample_freight.py`
|
||||
|
||||
- [x] **Step 1: 写混合批次隐私失败测试**
|
||||
|
||||
```python
|
||||
def test_specialist_sees_batch_when_own_item_exists_but_only_own_items(): ...
|
||||
def test_specialist_response_omits_total_linked_sample_count(): ...
|
||||
def test_keyword_matching_other_specialist_item_does_not_reveal_batch(): ...
|
||||
def test_specialist_cannot_link_or_unlink_other_users_sample(): ...
|
||||
def test_specialist_cannot_update_shared_batch_header(): ...
|
||||
def test_manager_sees_only_manager_scope_items_with_full_fields(): ...
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_freight_scope.py -q`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
- [x] **Step 3: 实现 SQL 层过滤和有限写能力**
|
||||
|
||||
专员批次存在性由“至少一个自己可见明细”决定;详情 SQL 只加载本人明细;响应使用 `visibleItemCount`,不返回全批次 `linkedSampleCount`。专员可新建、关联、解绑自己样本,但不可更新共享批次头。经理仅可查看和维护 manager scope 内明细;批次中其他地区明细不可泄露。
|
||||
|
||||
- [x] **Step 4: 验证运费与现有 CRUD 回归**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_freight_scope.py tests/test_sample_freight.py -q`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 8: 菜单 SQL 与小程序受控市场 web-view
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-10-10-market-mobile-menus.sql`
|
||||
- Create: `mobile-h5/utils/market-h5.js`
|
||||
- Create: `mobile-h5/utils/market-h5.test.js`
|
||||
- Create: `mobile-h5/pages/market/webview/index.vue`
|
||||
- Modify: `mobile-h5/pages.json`
|
||||
- Modify: `mobile-h5/utils/menu.js`
|
||||
- Modify: `mobile-h5/components/MenuGrid.vue`
|
||||
- Modify: `mobile-h5/styles/components/menu-grid.scss`
|
||||
- Modify: `mobile-h5/utils/oa-navigate-back.js`
|
||||
|
||||
- [x] **Step 1: 写十二入口白名单与 icon 失败测试**
|
||||
|
||||
```js
|
||||
assert.equal(resolveMarketEntry('specialist-presign').path, '/market/specialist/presign')
|
||||
assert.equal(resolveMarketEntry('manager-freight').path, '/market/manager/freight')
|
||||
assert.equal(resolveMarketEntry('https://evil.example'), null)
|
||||
assert.equal(resolveMarketEntry('../manager/freight'), null)
|
||||
assert.equal(normalizeMenu({ icon: '' }).icon, '')
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd mobile-h5 && node --test utils/market-h5.test.js`
|
||||
|
||||
Expected: FAIL,模块不存在。
|
||||
|
||||
- [x] **Step 3: 实现固定 entry 映射和 web-view**
|
||||
|
||||
`market-h5.js` 只导出十二个固定 entry,使用 `OA_H5_ORIGIN`、token 与验证后的 `returnPath` 构造 URL。web-view 不接受任意 URL;无 token 走 `silentWechatLogin`;未知 entry 提示后返回首页。菜单保留显式空 icon,宫格不渲染 `VanIcon`,显示 `groupTitle` 角色标签。
|
||||
|
||||
SQL 使用幂等 `INSERT ... SELECT ... WHERE NOT EXISTS`,目录 path 为空,叶子 path 为固定 `entry`,权限码分别为 `mini:market:{role}:{module}`。菜单属于系统种子 DML;本仓库全量业务表基线不包含 `sys_*` 种子,因此只维护迁移脚本,不向 `docs/sql/zkxw.sql` 追加孤立菜单记录。
|
||||
|
||||
- [x] **Step 4: 验证 Node、菜单 API 与 SQL 静态检查**
|
||||
|
||||
Run: `cd mobile-h5 && node --test utils/market-h5.test.js utils/oa-corner-index.test.js`
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_mini_menu.py -q`
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_mobile_menu_sql.py -q`
|
||||
|
||||
Expected: 测试 PASS,SQL 中十二个叶子权限码全部存在。
|
||||
|
||||
### Task 9: H5 十二路由、公共组件与只读页面
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-oa-h5/src/market/routes.js`
|
||||
- Create: `mobile-oa-h5/src/market/routes.test.js`
|
||||
- Create: `mobile-oa-h5/src/api/market.js`
|
||||
- Create: `mobile-oa-h5/src/market/components/MarketPageShell.vue`
|
||||
- Create: `mobile-oa-h5/src/market/components/MarketListCard.vue`
|
||||
- Create: `mobile-oa-h5/src/market/components/MarketDetailSection.vue`
|
||||
- Create: `mobile-oa-h5/src/market/components/MarketBottomBar.vue`
|
||||
- Create: `mobile-oa-h5/src/views/market/MarketModuleList.vue`
|
||||
- Create: `mobile-oa-h5/src/views/market/MarketModuleDetail.vue`
|
||||
- Create: `mobile-oa-h5/src/styles/market.css`
|
||||
- Modify: `mobile-oa-h5/src/router.js`
|
||||
- Modify: `mobile-oa-h5/src/main.js`
|
||||
|
||||
- [x] **Step 1: 写十二静态入口与 scope 不串失败测试**
|
||||
|
||||
```js
|
||||
assert.equal(routes.length, 12)
|
||||
assert.equal(find('/market/specialist/presign').meta.marketRole, 'specialist')
|
||||
assert.equal(find('/market/manager/presign').meta.marketRole, 'manager')
|
||||
assert.deepEqual(new Set(routes.map((r) => r.meta.module)),
|
||||
new Set(['presign', 'protocol', 'payment-chase', 'payment', 'sample', 'freight']))
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd mobile-oa-h5 && node --test src/market/routes.test.js`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
- [x] **Step 3: 实现公共移动列表与详情骨架**
|
||||
|
||||
路由 meta 固定角色和模块,API 路径从 meta 映射,不读取 query 中的 scope。列表支持搜索、筛选、分页、加载/空/错误态;详情使用分区卡片和状态 Tag;底栏仅呈现后端返回的 `allowedActions`。市场样式局部使用三墙主色 `#0a9f6d`,不修改 OA 全局 token,也不引入 web-admin Fresh Mint 主色。
|
||||
|
||||
- [x] **Step 4: 验证路由和生产构建**
|
||||
|
||||
Run: `cd mobile-oa-h5 && node --test src/market/routes.test.js`
|
||||
|
||||
Run: `cd mobile-oa-h5 && npm run build`
|
||||
|
||||
Expected: PASS,Vite build exit 0。
|
||||
|
||||
### Task 10: 六模块表单与有限业务动作
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-oa-h5/src/views/market/MarketModuleForm.vue`
|
||||
- Create: `mobile-oa-h5/src/market/modules/presign.js`
|
||||
- Create: `mobile-oa-h5/src/market/modules/protocol.js`
|
||||
- Create: `mobile-oa-h5/src/market/modules/payment-chase.js`
|
||||
- Create: `mobile-oa-h5/src/market/modules/payment.js`
|
||||
- Create: `mobile-oa-h5/src/market/modules/sample.js`
|
||||
- Create: `mobile-oa-h5/src/market/modules/freight.js`
|
||||
- Create: `mobile-oa-h5/src/market/module-actions.test.js`
|
||||
- Modify: `mobile-oa-h5/src/market/routes.js`
|
||||
- Modify: `mobile-oa-h5/src/api/market.js`
|
||||
|
||||
- [x] **Step 1: 写每模块动作白名单失败测试**
|
||||
|
||||
```js
|
||||
assert.deepEqual(actions('payment'), ['view', 'register-transfer', 'submit-finance', 'withdraw'])
|
||||
assert.equal(actions('payment').includes('finance-confirm'), false)
|
||||
assert.equal(actions('protocol').includes('audit'), false)
|
||||
assert.equal(actions('sample').includes('qc-conclusion'), false)
|
||||
assert.equal(actions('freight', 'specialist').includes('edit-shared-header'), false)
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认 RED**
|
||||
|
||||
Run: `cd mobile-oa-h5 && node --test src/market/module-actions.test.js`
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
- [x] **Step 3: 实现模块配置和单列移动表单**
|
||||
|
||||
预签:分区表单、意向金子卡、转正式。协议:细胞/科研 Tab,采集→客户→样本→缴费(仅细胞)→附件/确认。催缴:只读卡片并跳登记缴费。缴费:应缴期与转账登记表单。样本:进度详情与轻量办理白名单。运费:批次卡片、可见样本选择、关联/解绑;经理显示后端允许的批次头编辑。
|
||||
|
||||
所有按钮防重复提交;附件立即上传;提交中禁用;长表单返回前提示未保存;后端 401 提示返回小程序重新登录,403/404 使用不泄露对象存在性的文案。
|
||||
|
||||
- [x] **Step 4: 验证动作和构建**
|
||||
|
||||
Run: `cd mobile-oa-h5 && node --test src/market/module-actions.test.js src/market/routes.test.js`
|
||||
|
||||
Run: `cd mobile-oa-h5 && npm run build`
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
### Task 11: 全链路回归、字段一致性、知识图谱与总结
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-10-10-market-miniapp-role-features-implementation-summary.md`
|
||||
- Modify: `docs/superpowers/plans/2026-10-10-market-miniapp-role-features.md`(勾选完成项并记录实测命令)
|
||||
- Generated: `backend/graphify-out/*`
|
||||
|
||||
- [x] **Step 1: 后端定向回归**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
PYTHONPATH=. venv/bin/python -m pytest \
|
||||
tests/test_market_mobile_permissions.py \
|
||||
tests/test_mini_menu.py \
|
||||
tests/test_market_field_scope.py \
|
||||
tests/test_market_presign_api.py \
|
||||
tests/test_market_protocol_api.py \
|
||||
tests/test_market_payment_api.py \
|
||||
tests/test_market_sample_api.py \
|
||||
tests/test_market_freight_scope.py -q
|
||||
```
|
||||
|
||||
Expected: 全部 PASS。
|
||||
|
||||
2026-10-11 实测:计划内 8 个市场测试文件 `215 passed`;另补 3 个 SQL 测试文件 `11 passed`。
|
||||
|
||||
- [x] **Step 2: 后端全量测试与字段一致性**
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest -q`
|
||||
|
||||
Run: `cd backend && PYTHONPATH=. venv/bin/python scripts/check_field_consistency.py`
|
||||
|
||||
Expected: exit 0;若存在与本任务无关的基线失败,在总结中逐项记录,不得宣称全量通过。
|
||||
|
||||
2026-10-11 最终实测:全量 `912 passed, 4 failed`,4 项均为已确认既有基线;字段一致性 `0 errors, 5 warnings`,脚本 exit 1,且不覆盖本次 market DTO。详见实现总结。
|
||||
|
||||
- [x] **Step 3: 移动端测试与构建**
|
||||
|
||||
Run: `cd mobile-h5 && node --test utils/*.test.js utils/*.test.mjs`
|
||||
|
||||
Run: `cd mobile-oa-h5 && node --test $(find src -name '*.test.js' -print)`
|
||||
|
||||
Run: `cd mobile-oa-h5 && npm run build`
|
||||
|
||||
Expected: 全部 exit 0。
|
||||
|
||||
2026-10-11 最终实测:`mobile-h5 5/5 passed`;`mobile-oa-h5 62/62 passed`;Vite 构建 exit 0,仅有既有大 chunk 警告。
|
||||
|
||||
- [ ] **Step 4: 手工验收矩阵**
|
||||
|
||||
2026-10-11:未执行真实浏览器多视口、微信开发者工具和真实账号/MinIO 联调;自动路由、入口白名单、测试与构建已通过。该步骤保留待测试环境手工验收,不宣称通过。
|
||||
|
||||
在 375px、390px、430px 视口以及微信开发者工具中验证:
|
||||
|
||||
1. 只授权专员时只出现专员六项;只授权经理时反之;兼岗两组可辨。
|
||||
2. 十二入口均进入 `mobile-oa-h5`,未知 entry 无法打开任意 URL。
|
||||
3. 专员与经理列表、详情、编辑 scope 不串;猜测 ID 返回 404/403。
|
||||
4. 细胞/科研协议表单字段分流正确;催缴不出现虚假“编辑催缴”。
|
||||
5. 缴费无财务确认/开票/退款动作;样本无质检/生产动作。
|
||||
6. 混合运费批次不泄露他人明细或总数量;专员不能改共享批次头。
|
||||
7. 键盘、安全区、固定底栏、返回宿主、token 失效处理正常。
|
||||
|
||||
- [x] **Step 5: 更新代码知识图谱**
|
||||
|
||||
Run: `./scripts/update-graph.sh backend`
|
||||
|
||||
Expected: backend 图谱生成成功;本任务未修改 `web-admin`,无需更新 frontend 图谱。
|
||||
|
||||
2026-10-11 实测:exit 0,最终图谱为 `6122 nodes / 33427 edges / 40 communities`。
|
||||
|
||||
- [x] **Step 6: 写实现总结**
|
||||
|
||||
总结文档必须包含:架构与安全边界、菜单与路由清单、六模块实际开放动作、scope 规则、运费脱敏、SQL 迁移步骤、部署/回滚、自动测试结果、手工验收结果、已知限制与后续 icon 任务。不得使用 TBD/TODO。
|
||||
|
||||
2026-10-11 已生成 `docs/superpowers/specs/2026-10-10-market-miniapp-role-features-implementation-summary.md`。
|
||||
|
||||
## 实施约束
|
||||
|
||||
- 所有生产代码遵循 RED → GREEN → REFACTOR;未观察到目标测试失败前不得写实现。
|
||||
- 子代理每次只负责一个任务域;完成后先做规格符合性审查,再做代码质量审查。
|
||||
- 不修改或提交当前工作区中与本任务无关的未跟踪文件。
|
||||
- 不直接放宽全局 `require_menu_code()`;移动市场权限使用专用严格守卫。
|
||||
- 不信任客户端的 tenant、scope、sales/manager 身份覆盖字段。
|
||||
- 不把桌面 Element Plus 页面复制进移动 H5。
|
||||
- 新增/修改后端文件、函数签名或 import 后必须执行 `./scripts/update-graph.sh backend`。
|
||||
@@ -0,0 +1,273 @@
|
||||
# Market Attachment Parity 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:** Remove the floating round back button from every market detail page and render market attachments with the workflow-standard thumbnail, file-icon, download, and editable-delete experience.
|
||||
|
||||
**Architecture:** Add a pure attachment normalizer and one shared market attachment-card component. Form fields and presign uploads retain their current upload APIs but delegate display, preview, download, and remove interactions to the shared component; detail adapters expose typed attachment fields consumed by `MarketDetailSection.vue`.
|
||||
|
||||
**Tech Stack:** Vue 3 Composition API, Vant 4, native Node test runner, Vite 6.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
- Create `mobile-oa-h5/src/market/attachment-presenter.js`: normalize supported attachment shapes, derive safe display names, image classification, and workflow file kinds.
|
||||
- Create `mobile-oa-h5/src/market/attachment-presenter.test.js`: unit coverage for strings, arrays, object metadata, image detection, and file kinds.
|
||||
- Create `mobile-oa-h5/src/market/components/MarketAttachmentCards.vue`: workflow-standard 84×84 rendering, upload trigger, image preview, file download, and editable remove event.
|
||||
- Modify `mobile-oa-h5/src/market/components/MarketFormField.vue`: replace raw URL anchors with the shared component while preserving comma-separated form values.
|
||||
- Modify `mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue`: reuse the shared renderer and preserve existing upload/remove events.
|
||||
- Modify `mobile-oa-h5/src/market/components/MarketDetailSection.vue`: support `type: 'attachments'` in top-level and nested detail fields.
|
||||
- Modify `mobile-oa-h5/src/market/modules/protocol.js`: expose protocol attachment values as typed attachments instead of joined path text.
|
||||
- Modify `mobile-oa-h5/src/market/modules/payment.js`: expose transfer vouchers as typed attachments.
|
||||
- Modify `mobile-oa-h5/src/market/modules/sample.js`: expose handling attachments as typed attachments.
|
||||
- Modify `mobile-oa-h5/src/market/modules/presign.js`: expose presign transfer attachments as typed attachments.
|
||||
- Modify `mobile-oa-h5/src/views/market/MarketModuleDetail.vue`: disable the detail-page back control.
|
||||
- Modify `mobile-oa-h5/src/market/mobile-parity-ui.test.js`: lock the visual and detail attachment contracts.
|
||||
- Modify `mobile-oa-h5/src/market/module-contracts.test.js`: lock typed attachment fields across adapters.
|
||||
|
||||
### Task 1: Pure attachment normalization
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-oa-h5/src/market/attachment-presenter.js`
|
||||
- Create: `mobile-oa-h5/src/market/attachment-presenter.test.js`
|
||||
|
||||
- [ ] **Step 1: Write failing unit tests**
|
||||
|
||||
Cover these public functions:
|
||||
|
||||
```js
|
||||
normalizeMarketAttachments('https://cdn/a%20b.pdf,https://cdn/photo.png')
|
||||
// => [{ url: '...', name: 'a b.pdf', isImage: false, kind: 'pdf' }, ...]
|
||||
|
||||
normalizeMarketAttachments([
|
||||
{ fileUrl: '/minio/x/report.xlsx', fileName: '费用明细.xlsx' }
|
||||
])
|
||||
// preserves fileName and raw URL
|
||||
|
||||
marketAttachmentKind('archive.zip') // => 'zip'
|
||||
isMarketAttachmentImage({ name: 'photo.webp' }) // => true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5
|
||||
node --test src/market/attachment-presenter.test.js
|
||||
```
|
||||
|
||||
Expected: FAIL because `attachment-presenter.js` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal pure utility**
|
||||
|
||||
Implement safe URL decoding, comma splitting, array/object normalization, these image extensions:
|
||||
|
||||
```js
|
||||
const IMAGE_RE = /\.(?:jpe?g|png|gif|bmp|webp|svg|jfif|avif|heic|heif)(?:\?|$)/i
|
||||
```
|
||||
|
||||
and these file kinds:
|
||||
|
||||
```js
|
||||
pdf: pdf
|
||||
xls: xls, xlsx, csv
|
||||
doc: doc, docx, ppt, pptx
|
||||
zip: zip, rar, 7z
|
||||
other: every remaining file
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the focused test and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: all tests pass.
|
||||
|
||||
### Task 2: Shared workflow-standard attachment cards
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-oa-h5/src/market/components/MarketAttachmentCards.vue`
|
||||
- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
|
||||
|
||||
- [ ] **Step 1: Write failing component-contract assertions**
|
||||
|
||||
Assert the component contains:
|
||||
|
||||
```js
|
||||
assert.match(source, /normalizeMarketAttachments/)
|
||||
assert.match(source, /downloadAttachmentFile/)
|
||||
assert.match(source, /showImagePreview/)
|
||||
assert.match(source, /market-attachment-card__delete/)
|
||||
assert.match(source, /@error=/)
|
||||
assert.match(source, /84px/)
|
||||
```
|
||||
|
||||
Also assert file-icon kinds `ft-pdf`, `ft-xls`, `ft-doc`, `ft-zip`, and `ft-other` exist.
|
||||
|
||||
- [ ] **Step 2: Run the contract test and verify RED**
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5
|
||||
node --test src/market/mobile-parity-ui.test.js
|
||||
```
|
||||
|
||||
Expected: FAIL because the component does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the shared component**
|
||||
|
||||
Required props and events:
|
||||
|
||||
```js
|
||||
defineProps({
|
||||
modelValue: { type: [Array, String, Object], default: '' },
|
||||
editable: { type: Boolean, default: false },
|
||||
uploading: { type: Boolean, default: false },
|
||||
maxSize: { type: Number, default: MARKET_ATTACHMENT_MAX_BYTES },
|
||||
accept: { type: String, default: MARKET_ATTACHMENT_ACCEPT }
|
||||
})
|
||||
defineEmits(['upload', 'remove'])
|
||||
```
|
||||
|
||||
Use `van-uploader` for the first add card. Match backend validation: 10 MiB by default, 20 MiB for presign, and only JPG/JPEG, PNG, WebP, HEIC/HEIF, PDF, DOC/DOCX, XLS/XLSX. Use image preview through `showImagePreview`, and non-image download through `downloadAttachmentFile`. Image `error` changes that card to the `other` file icon. Copy the approved 84×84 workflow styling and keep a visually small delete button with an unclipped 44×44px interactive hit area. When a name was inferred from a URL, omit `fileName` from the download request so the backend restores the stored original name.
|
||||
|
||||
- [ ] **Step 4: Run the contract test and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: all assertions pass.
|
||||
|
||||
### Task 3: Form integration and editable deletion
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-oa-h5/src/market/components/MarketFormField.vue`
|
||||
- Modify: `mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue`
|
||||
- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
|
||||
|
||||
- [ ] **Step 1: Add failing integration contracts**
|
||||
|
||||
Assert both form components import and render `MarketAttachmentCards`. Assert `MarketFormField.vue` updates a comma-separated URL list on remove, and `PresignAttachmentUploader.vue` forwards `upload` and `remove` events.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5
|
||||
node --test src/market/mobile-parity-ui.test.js
|
||||
```
|
||||
|
||||
Expected: FAIL because both components still use their separate raw-card implementations.
|
||||
|
||||
- [ ] **Step 3: Integrate the shared component**
|
||||
|
||||
For generic fields:
|
||||
|
||||
```vue
|
||||
<MarketAttachmentCards
|
||||
:model-value="modelValue"
|
||||
:editable="!disabled && !field.readOnly"
|
||||
:uploading="uploading"
|
||||
@upload="$emit('upload', $event)"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
```
|
||||
|
||||
`removeAttachment(index)` must normalize the current value, remove the selected item, then emit a comma-separated URL string. For presign attachments, preserve the current array value and existing parent-owned `remove(index)` behavior.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: all assertions pass.
|
||||
|
||||
### Task 4: Read-only detail attachments and back-button removal
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-oa-h5/src/market/components/MarketDetailSection.vue`
|
||||
- Modify: `mobile-oa-h5/src/market/modules/protocol.js`
|
||||
- Modify: `mobile-oa-h5/src/market/modules/payment.js`
|
||||
- Modify: `mobile-oa-h5/src/market/modules/sample.js`
|
||||
- Modify: `mobile-oa-h5/src/market/modules/presign.js`
|
||||
- Modify: `mobile-oa-h5/src/views/market/MarketModuleDetail.vue`
|
||||
- Modify: `mobile-oa-h5/src/market/module-contracts.test.js`
|
||||
- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
|
||||
|
||||
- [ ] **Step 1: Write failing detail contracts**
|
||||
|
||||
Assert:
|
||||
|
||||
```js
|
||||
assert.match(detailView, /:backable="false"/)
|
||||
assert.doesNotMatch(detailView, /@back="backToList"/)
|
||||
```
|
||||
|
||||
For each adapter, build representative details and verify every attachment-bearing field uses:
|
||||
|
||||
```js
|
||||
{ label: '...', type: 'attachments', value: originalAttachmentValue }
|
||||
```
|
||||
|
||||
Also assert `MarketDetailSection.vue` renders `MarketAttachmentCards` with `:editable="false"` for top-level, nested, and subcard fields.
|
||||
|
||||
- [ ] **Step 2: Run both focused tests and verify RED**
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5
|
||||
node --test src/market/mobile-parity-ui.test.js src/market/module-contracts.test.js
|
||||
```
|
||||
|
||||
Expected: FAIL because detail fields still display attachment paths and the detail view still enables the floating back button.
|
||||
|
||||
- [ ] **Step 3: Implement typed attachment detail fields**
|
||||
|
||||
Preserve the original attachment value in every adapter and add `type: 'attachments'`. Extend `normalizeCard()` and top-level field normalization in `MarketDetailSection.vue` to retain the type. Render the shared component when the type matches, otherwise keep the existing tag/text behavior.
|
||||
|
||||
Change the detail shell to:
|
||||
|
||||
```vue
|
||||
<MarketPageShell
|
||||
:title="moduleTitle"
|
||||
:eyebrow="`${roleTitle} · 详情`"
|
||||
:show-header="false"
|
||||
:backable="false"
|
||||
:has-bottom-bar="allowedActions.length > 0"
|
||||
>
|
||||
```
|
||||
|
||||
Remove the now-unused `backToList` handler only if no other code path calls it; otherwise retain the handler but remove the template listener.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: all assertions pass.
|
||||
|
||||
### Task 5: Full verification and graph impact check
|
||||
|
||||
**Files:**
|
||||
- Verify all modified files above.
|
||||
|
||||
- [ ] **Step 1: Run all mobile Node tests**
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5
|
||||
node --test $(rg --files src | rg '\.test\.js$')
|
||||
```
|
||||
|
||||
Expected: zero failures.
|
||||
|
||||
- [ ] **Step 2: Run the production build**
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: Vite build exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Check formatting and unintended whitespace**
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: no output and exit code 0.
|
||||
|
||||
- [ ] **Step 4: Inspect the final diff against the design**
|
||||
|
||||
Verify all acceptance criteria from `docs/superpowers/specs/2026-10-11-market-attachment-parity-design.md`, especially that no raw attachment path is rendered in the four attachment-bearing detail adapters.
|
||||
|
||||
- [ ] **Step 5: Skip knowledge-graph regeneration**
|
||||
|
||||
This change modifies Vue templates, local CSS, and pure presentation helpers without changing function signatures or import relationships outside `mobile-oa-h5`; the repository knowledge-graph rule only has generated graphs for `backend` and `web-admin`, so no graph update command applies.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Market List Card Right Spacing 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:** 修复市场部通用列表卡片右侧溢出遮挡,并确认 12 个市场入口的卡片布局均受到回归保护。
|
||||
|
||||
**Architecture:** 保留现有通用卡片的 `width: 100%` 和间距,通过 `box-sizing: border-box` 将 padding 与 border 纳入宽度计算。预签约页面使用独立卡片,不修改其布局;回归测试同时校验通用卡片盒模型、预签约卡片宽度约束和 12 个路由覆盖。
|
||||
|
||||
**Tech Stack:** Vue 3, Vant 4, CSS, Node.js `node:test`, Vite
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add a failing card-width regression test
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
|
||||
- Test: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
|
||||
|
||||
- [x] **Step 1: Write the failing test**
|
||||
|
||||
Add a test that reads `styles/market.css` and `routes.js`, then asserts:
|
||||
|
||||
```js
|
||||
test('all twelve market list entries keep cards inside the horizontal content area', () => {
|
||||
const styles = source('../styles/market.css')
|
||||
const routes = source('./routes.js')
|
||||
|
||||
assert.match(styles, /\.market-list-card\s*\{[\s\S]*?box-sizing:\s*border-box;[\s\S]*?width:\s*100%;/)
|
||||
assert.match(styles, /\.presign-order-card\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||
assert.match(routes, /export const MARKET_ROLES = \['specialist', 'manager'\]/)
|
||||
assert.match(routes, /export const MARKET_MODULES = \[[\s\S]*?'presign'[\s\S]*?'protocol'[\s\S]*?'payment-chase'[\s\S]*?'payment'[\s\S]*?'sample'[\s\S]*?'freight'/)
|
||||
})
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5 && node --test --test-name-pattern="all twelve market list entries" src/market/mobile-parity-ui.test.js
|
||||
```
|
||||
|
||||
Expected: one failed test because `.market-list-card` does not contain `box-sizing: border-box` before `width: 100%`.
|
||||
|
||||
### Task 2: Apply the minimal CSS fix
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-oa-h5/src/styles/market.css`
|
||||
- Test: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
|
||||
|
||||
- [x] **Step 1: Add the width-safe box model**
|
||||
|
||||
Update the existing rule to begin with:
|
||||
|
||||
```css
|
||||
.market-list-card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run the focused test and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5 && node --test --test-name-pattern="all twelve market list entries" src/market/mobile-parity-ui.test.js
|
||||
```
|
||||
|
||||
Expected: the focused test passes.
|
||||
|
||||
### Task 3: Verify the market UI suite, build, and dependency graph
|
||||
|
||||
**Files:**
|
||||
- Verify: `mobile-oa-h5/src/market/*.test.js`
|
||||
- Verify: `mobile-oa-h5/src/styles/market.css`
|
||||
- Update generated graph: `web-admin/graphify-out/*` only if the project graph script maps `mobile-oa-h5` into the frontend graph
|
||||
|
||||
- [x] **Step 1: Run all market UI tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5 && node --test src/market/*.test.js
|
||||
```
|
||||
|
||||
Expected: zero failed tests.
|
||||
|
||||
- [x] **Step 2: Run the production build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd mobile-oa-h5 && pnpm build
|
||||
```
|
||||
|
||||
Expected: Vite exits with code 0.
|
||||
|
||||
- [x] **Step 3: Update the frontend knowledge graph**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/update-graph.sh frontend
|
||||
```
|
||||
|
||||
Expected: graph generation exits with code 0. Preserve generated changes only when the script includes the modified mobile frontend files.
|
||||
|
||||
- [x] **Step 4: Check the final diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check -- mobile-oa-h5/src/styles/market.css mobile-oa-h5/src/market/mobile-parity-ui.test.js docs/superpowers/plans/2026-10-11-market-list-card-right-spacing.md
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no whitespace errors; only the intended incremental edits are attributed to this fix.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Market Reference Labels 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:** Ensure every Market mobile list, detail, create, and edit view displays Chinese business labels instead of internal user IDs, foreign-key IDs, or dictionary codes.
|
||||
|
||||
**Architecture:** Keep backend permission-scoped option lists unchanged, but hydrate each edit form with the current record's authoritative `{ value, label }` snapshots so historical values remain readable. Centralize frontend display fallback rules so unresolved references render `未知人员` or `—`, never raw IDs/codes, and enrich sample handling responses with handler names at the backend boundary.
|
||||
|
||||
**Tech Stack:** Vue 3, Vant, JavaScript Node test runner, FastAPI, SQLAlchemy, pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add reference-label regression coverage
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-oa-h5/src/market/reference-options.test.js`
|
||||
- Modify: `mobile-oa-h5/src/market/module-contracts.test.js`
|
||||
- Modify: `mobile-oa-h5/src/market/list-presenter.test.js`
|
||||
- Modify: `backend/tests/test_market_sample_api.py`
|
||||
|
||||
- [ ] **Step 1: Write failing frontend tests**
|
||||
|
||||
Add tests proving selected user, region, hospital, sample type, sample code, and unit values are merged into scoped option sets from detail label fields. Assert protocol/sample/freight detail presenters never fall back to IDs or dictionary codes.
|
||||
|
||||
- [ ] **Step 2: Run the focused frontend tests and verify RED**
|
||||
|
||||
Run: `node --test src/market/reference-options.test.js src/market/module-contracts.test.js src/market/list-presenter.test.js`
|
||||
|
||||
Expected: FAIL because reference-option hydration and safe label fallbacks do not yet exist.
|
||||
|
||||
- [ ] **Step 3: Write failing backend tests**
|
||||
|
||||
Extend sample API tests so `get_sample_detail` returns `handlerUserName` for storage-certificate and insurance summaries and `get_sample_form_options` retains the currently selected handler as a readable option without widening normal region staff choices.
|
||||
|
||||
- [ ] **Step 4: Run the focused backend tests and verify RED**
|
||||
|
||||
Run: `backend/venv/bin/pytest backend/tests/test_market_sample_api.py -q`
|
||||
|
||||
Expected: FAIL on missing handler display names/current selected options.
|
||||
|
||||
### Task 2: Centralize form reference-option hydration
|
||||
|
||||
**Files:**
|
||||
- Create: `mobile-oa-h5/src/market/reference-options.js`
|
||||
- Modify: `mobile-oa-h5/src/views/market/MarketModuleForm.vue`
|
||||
- Modify: `mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue`
|
||||
|
||||
- [ ] **Step 1: Implement minimal hydration helpers**
|
||||
|
||||
Create pure helpers that merge scoped options with current detail snapshots, deduplicate by normalized value, and only add entries with a real human-readable label.
|
||||
|
||||
- [ ] **Step 2: Hydrate generic Market forms after detail/options load**
|
||||
|
||||
For protocol fields, merge current collector, sales specialist, manager, region, hospital, sample type, sample code, and unit labels. For sample handling, merge the current handler name. Reapply hydration after region cascade option refreshes.
|
||||
|
||||
- [ ] **Step 3: Harden the dedicated pre-sign form**
|
||||
|
||||
Reuse the shared person-option merge behavior and render `未知人员` rather than the raw selected ID if the backend cannot resolve a name.
|
||||
|
||||
- [ ] **Step 4: Run focused frontend tests and verify GREEN**
|
||||
|
||||
Run: `node --test src/market/reference-options.test.js src/market/module-contracts.test.js src/market/list-presenter.test.js src/market/presign/presign-form-state.test.js src/market/presign/presign-form-view.test.js`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
### Task 3: Enrich sample handler names without widening permissions
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/market_sample.py`
|
||||
- Modify: `backend/tests/test_market_sample_api.py`
|
||||
|
||||
- [ ] **Step 1: Resolve handler names in sample detail summaries**
|
||||
|
||||
Batch-load tenant user display names for storage-certificate and insurance handler IDs and expose them as `handlerUserName`.
|
||||
|
||||
- [ ] **Step 2: Preserve the current handler in edit options**
|
||||
|
||||
Append only the handler already referenced by the accessible sample record when it is outside the active region-staff list; do not expose unrelated tenant users.
|
||||
|
||||
- [ ] **Step 3: Run focused backend tests and verify GREEN**
|
||||
|
||||
Run: `backend/venv/bin/pytest backend/tests/test_market_sample_api.py -q`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
### Task 4: Remove raw ID/code display fallbacks across Market presenters
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-oa-h5/src/market/modules/protocol.js`
|
||||
- Modify: `mobile-oa-h5/src/market/modules/sample.js`
|
||||
- Modify: `mobile-oa-h5/src/market/modules/freight.js`
|
||||
- Modify: `mobile-oa-h5/src/market/list-presenter.js`
|
||||
- Modify: `backend/app/services/market_freight.py`
|
||||
|
||||
- [ ] **Step 1: Replace person-ID fallbacks**
|
||||
|
||||
Display authoritative name fields, `未知人员` for a non-empty unresolved person reference, and `—` when no reference exists.
|
||||
|
||||
- [ ] **Step 2: Replace unresolved dictionary-code fallbacks**
|
||||
|
||||
For status, source, payment mode, transport mode, sex, and delivery mode, use known Chinese mappings and render `未知` for an unrecognized non-empty code instead of exposing the code.
|
||||
|
||||
- [ ] **Step 3: Keep identifiers that are business numbers**
|
||||
|
||||
Do not alter protocol numbers, sample codes, freight batch numbers, laboratory storage codes, or other user-facing business identifiers.
|
||||
|
||||
- [ ] **Step 4: Run presenter and contract tests**
|
||||
|
||||
Run: `node --test src/market/list-presenter.test.js src/market/module-contracts.test.js src/market/mobile-parity-ui.test.js`
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
### Task 5: Full verification and browser audit
|
||||
|
||||
**Files:**
|
||||
- Modify generated graph files under `backend/graphify-out/` only through the repository script.
|
||||
|
||||
- [ ] **Step 1: Run all mobile Market tests**
|
||||
|
||||
Run: `node --test src/market/*.test.js src/market/presign/*.test.js src/utils/download-attachment.test.js src/utils/http-error.test.js`
|
||||
|
||||
Expected: zero failures.
|
||||
|
||||
- [ ] **Step 2: Build mobile OA H5**
|
||||
|
||||
Run: `npm run build`
|
||||
|
||||
Expected: Vite exits with code 0.
|
||||
|
||||
- [ ] **Step 3: Run affected backend suites**
|
||||
|
||||
Run: `backend/venv/bin/pytest backend/tests/test_market_sample_api.py backend/tests/test_market_protocol_api.py backend/tests/test_market_support_api.py backend/tests/test_market_freight_scope.py -q`
|
||||
|
||||
Expected: zero failures.
|
||||
|
||||
- [ ] **Step 4: Update backend code graph**
|
||||
|
||||
Run: `./scripts/update-graph.sh backend`
|
||||
|
||||
Expected: graph update exits with code 0.
|
||||
|
||||
- [ ] **Step 5: Verify representative pages in the browser**
|
||||
|
||||
Open specialist and manager protocol edit, pre-sign edit, sample handling, payment/chase detail, and freight detail. Confirm reference fields show Chinese labels, no internal UUID/user ID/dictionary code is visible, and valid business identifiers remain unchanged.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 市场端剩余五模块移动端复刻实施计划
|
||||
|
||||
> 执行方式:当前会话内直接实施。视觉与交互以 `docs/demos/2026-10-11-market-remaining-mobile-parity-demo.html` 为准。
|
||||
|
||||
## 目标
|
||||
|
||||
将市场专员、地区经理的协议单、催缴信息、缴费信息、样本信息、运费信息共 10 个入口改造成与 Web 端字段、动作一致的移动端卡片界面;复用现有 `mobile-oa-h5` 市场模块路由和表单框架,不复制业务逻辑,不制造仅前端可见的假动作。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. **锁定展示契约**
|
||||
- 新增列表展示适配器及单元测试,覆盖五模块标题、状态、完整字段、金额格式、角色差异和动作标签。
|
||||
- 所有卡片动作与后端返回的 `allowedActions` 取交集,避免越权按钮。
|
||||
|
||||
2. **补齐后端移动端协议能力**
|
||||
- 为协议列表返回卡片所需字段。
|
||||
- 新增带业务来源参数的协议审核、软删除接口;访问控制继续经过租户、角色、数据范围校验。
|
||||
- 扩展协议 `allowedActions`,并以测试覆盖状态限制和路由契约。
|
||||
|
||||
3. **补齐其余列表摘要**
|
||||
- 缴费列表增加已缴、待缴、期次进度摘要。
|
||||
- 催缴列表补充协议状态。
|
||||
- 运费列表携带当前角色可见样本,并对市场专员只展示隐私提示,不泄露隐藏数量。
|
||||
|
||||
4. **重做移动端列表壳层**
|
||||
- 去除绿色大头部和状态下拉,只保留白色导航标题与搜索框。
|
||||
- 新增动作改为右下角 56px 圆形悬浮按钮。
|
||||
- 使用 Vant `List` 滚动触底分页加载,保留首屏失败、追加失败和空状态处理。
|
||||
- 卡片展示 Web 端同等信息,并提供详情、编辑、审核、删除及各模块专属动作。
|
||||
|
||||
5. **表单组件移动化**
|
||||
- 下拉字段统一改为 Vant Picker;日期时间统一改为 Vant DatePicker/TimePicker。
|
||||
- 采集地区变化后清空并重新加载采集医院,实现数据库选项级联。
|
||||
- 上传字段改为“上传卡片 + 已上传附件卡片”同排布局,选择后立即上传,支持预览和删除。
|
||||
|
||||
6. **验证与图谱**
|
||||
- 运行 `mobile-oa-h5` 市场单测及生产构建。
|
||||
- 运行后端市场模块定向测试。
|
||||
- 更新后端代码知识图谱(移动端 H5 暂无独立图谱任务)。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 两类角色的五模块列表均采用模板卡片布局,无状态筛选下拉。
|
||||
- 有新增权限的页面只显示右下角圆形 `+` 按钮。
|
||||
- 列表滚动自动分页,查询会重置分页并重新请求。
|
||||
- 列表和详情动作由后端授权,协议审核/删除真实可用。
|
||||
- 地区/医院联动、数据库下拉、附件卡片、缴费/样本/运费专属动作均可用。
|
||||
- 测试与构建通过,无跨应用主色混用。
|
||||
@@ -28,7 +28,7 @@
|
||||
- Modify: `mobile-h5/utils/config.js`
|
||||
- Create: `mobile-h5/utils/env-banner.test.js`
|
||||
|
||||
- [ ] **Step 1: 先写失败单测**
|
||||
- [x] **Step 1: 先写失败单测**
|
||||
|
||||
创建 `mobile-h5/utils/env-banner.test.js`:
|
||||
|
||||
@@ -60,7 +60,7 @@ assert.deepEqual(buildEnvBanner('unknown'), {
|
||||
console.log('env-banner.test.js: ok')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
- [x] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -70,7 +70,7 @@ cd mobile-h5 && node utils/env-banner.test.js
|
||||
|
||||
Expected: FAIL(`buildEnvBanner` 未导出 / 未定义)
|
||||
|
||||
- [ ] **Step 3: 实现 `buildEnvBanner` 与 `ENV_BANNER`**
|
||||
- [x] **Step 3: 实现 `buildEnvBanner` 与 `ENV_BANNER`**
|
||||
|
||||
在 `mobile-h5/utils/config.js` 末尾(现有导出之后)增加:
|
||||
|
||||
@@ -91,7 +91,7 @@ export const ENV_BANNER = buildEnvBanner(APP_ENV)
|
||||
|
||||
不要改动 `ENV_PRESETS` 与现有 URL 导出逻辑。若当前 `APP_ENV` 为本地联调值可保持不动(本任务不强制改回 `prod`)。
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
- [x] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -101,7 +101,7 @@ cd mobile-h5 && node utils/env-banner.test.js
|
||||
|
||||
Expected: 打印 `env-banner.test.js: ok`,exit code 0
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add mobile-h5/utils/config.js mobile-h5/utils/env-banner.test.js
|
||||
@@ -121,7 +121,7 @@ EOF
|
||||
**Files:**
|
||||
- Modify: `mobile-h5/pages/index/index.vue`
|
||||
|
||||
- [ ] **Step 1: 模板插入色条**
|
||||
- [x] **Step 1: 模板插入色条**
|
||||
|
||||
在 `<view class="page-shell">` 内、`<view class="home-header">` **之前**插入:
|
||||
|
||||
@@ -135,7 +135,7 @@ EOF
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: script 引入并挂到 data/computed**
|
||||
- [x] **Step 2: script 引入并挂到 data/computed**
|
||||
|
||||
在 script 顶部增加:
|
||||
|
||||
@@ -149,7 +149,7 @@ EOF
|
||||
envBanner: ENV_BANNER,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: scoped 样式**
|
||||
- [x] **Step 3: scoped 样式**
|
||||
|
||||
在现有 `<style lang="scss" scoped>` 中追加:
|
||||
|
||||
@@ -183,14 +183,14 @@ EOF
|
||||
|
||||
色条不加 status-bar 额外占位;安全区仍由现有 `.home-header` padding 处理。
|
||||
|
||||
- [ ] **Step 4: 手工验收清单**
|
||||
- [x] **Step 4: 手工验收清单**
|
||||
|
||||
1. `APP_ENV='local'` → 首页顶栏橙色「本地环境」
|
||||
2. `APP_ENV='test'` → 首页顶栏蓝色「测试环境」
|
||||
3. `APP_ENV='prod'` → 无色条
|
||||
4. 登录页 / OA 列表无色条
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add mobile-h5/pages/index/index.vue
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Remove Market Floating Back Buttons 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:** Remove every top-left circular back button from pages under the Market menu while preserving bottom cancel actions and normal list navigation.
|
||||
|
||||
**Architecture:** Generic Market pages continue to use `MarketPageShell`, with forms explicitly disabling its optional back control. The dedicated presign form removes its standalone back control. A source-contract test prevents either control from returning.
|
||||
|
||||
**Tech Stack:** Vue 3, Vant, Node.js test runner, Vite.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Remove Market form floating back controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js`
|
||||
- Modify: `mobile-oa-h5/src/views/market/MarketModuleForm.vue`
|
||||
- Modify: `mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue`
|
||||
|
||||
- [x] **Step 1: Write the failing test**
|
||||
|
||||
Extend the Market UI contract test so the generic form must pass `:backable="false"` and the dedicated presign form must not contain `presign-form-page__back`.
|
||||
|
||||
- [x] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test src/market/mobile-parity-ui.test.js`
|
||||
|
||||
Expected: FAIL because both form implementations currently render a back control.
|
||||
|
||||
- [x] **Step 3: Write minimal implementation**
|
||||
|
||||
Pass `:backable="false"` to `MarketPageShell` in the generic form and delete the standalone back button from the presign form. Keep the bottom cancel actions unchanged.
|
||||
|
||||
- [x] **Step 4: Run verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all tests pass and the production build succeeds.
|
||||
|
||||
- [x] **Step 5: Verify the original page**
|
||||
|
||||
Reload the protocol edit page and confirm its iframe accessibility tree contains no button named `返回`, while the bottom `取消` and `保存` buttons remain.
|
||||
@@ -20,7 +20,7 @@
|
||||
- Modify: `backend/tests/test_sample_payment_period_summary.py`
|
||||
- Modify: `backend/tests/test_payment_chase.py`
|
||||
|
||||
- [ ] **Step 1: 改写失败用例期望(TDD)**
|
||||
- [x] **Step 1: 改写失败用例期望(TDD)**
|
||||
|
||||
在 `test_sample_payment_period_summary.py`:
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 预签约协议编号自动发号 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:** 预签约新增保存自动发号、编辑空号补发且不可改号、表单不展示、租户内与正式/捐赠全局唯一;转正式沿用该号;同步 Web 与小程序移动端。
|
||||
|
||||
**Architecture:** 发号集中在 `presign_order` CRUD;唯一性扩展 `assert_biz_no_available`;前端仅去掉表单输入,列表仍展示接口返回的 `protocolNo`。
|
||||
|
||||
**Tech Stack:** FastAPI/SQLAlchemy、pytest、Vue 3(web-admin / mobile-oa-h5)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-12-presign-protocol-no-auto-allocate-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 唯一性校验纳入预签约
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/protocol_donation_convert.py`(`assert_biz_no_available`)
|
||||
- Test: `backend/tests/test_presign_order.py`(或新建聚焦测试)
|
||||
|
||||
- [x] 扩展 `assert_biz_no_available`:校验 `BizPresignOrder.protocol_no`,支持 `exclude_presign_order_id`
|
||||
- [x] 测试:预签已占号 → 正式创建同号失败;排除自身可通过
|
||||
|
||||
### Task 2: 创建/更新自动发号
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/presign_order.py`
|
||||
- Test: `backend/tests/test_presign_order.py`
|
||||
|
||||
- [x] `create_presign_order`:忽略客户端号,调用 `allocate_next_biz_no(kind="collection")`
|
||||
- [x] `update_presign_order`:去掉 mapping 中的 protocol_no;空号补发
|
||||
- [x] 测试:创建必有号;客户端自定义号被忽略;更新不能改号;空号编辑补发
|
||||
|
||||
### Task 3: Web 表单隐藏协议编号
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/presign-order/PresignOrderFormDialog.vue`
|
||||
|
||||
- [x] 移除表单项与提交中的 `protocolNo`(可保留内部只读状态字段若详情加载需要,但勿展示、勿提交改号)
|
||||
|
||||
### Task 4: 移动端 / 小程序表单同步
|
||||
|
||||
**Files:**
|
||||
- Modify: `mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue`
|
||||
- Modify: `mobile-oa-h5/src/market/presign/presign-form-state.js`(及 payload 若有)
|
||||
- Modify: `mobile-oa-h5/src/market/presign/presign-form-*.test.js`
|
||||
|
||||
- [x] 移除协议编号输入
|
||||
- [x] 更新契约测试字段列表
|
||||
|
||||
### Task 5: 验证
|
||||
|
||||
- [x] 跑相关 pytest 与移动端相关 vitest/node 测试
|
||||
- [x] 更新 graphify(backend)若签名变更
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# 存储业务移动端功能实施总结
|
||||
|
||||
## 1. 实施结果
|
||||
|
||||
本次已完成市场专员、地区经理在小程序中的存储业务入口,以及由 `mobile-oa-h5` 承载的六类业务功能:预签约订单、协议单、催缴、缴费、样本和运费。小程序只负责受控入口和 WebView 宿主,业务列表、详情、表单与有限动作均在 H5 中实现。
|
||||
|
||||
业务链路为:
|
||||
|
||||
```text
|
||||
mobile-h5 首页菜单
|
||||
-> /pages/market/webview/index?entry=<固定入口>
|
||||
-> mobile-oa-h5 固定角色路由
|
||||
-> /api/mini/market/{specialist|manager}/...
|
||||
```
|
||||
|
||||
客户端不能传入任意 H5 URL、租户、现场 scope 或销售/经理身份来扩大权限。
|
||||
|
||||
## 2. 菜单与入口
|
||||
|
||||
菜单迁移为每个移动端虚拟根创建两个目录和十二个叶子,不自动写入角色菜单关系:
|
||||
|
||||
| 角色组 | 六个固定入口 |
|
||||
|---|---|
|
||||
| 市场专员 | `specialist-presign`、`specialist-protocol`、`specialist-payment-chase`、`specialist-payment`、`specialist-sample`、`specialist-freight` |
|
||||
| 地区经理 | `manager-presign`、`manager-protocol`、`manager-payment-chase`、`manager-payment`、`manager-sample`、`manager-freight` |
|
||||
|
||||
首页接口返回 `groupTitle` 区分角色组。目录本身没有可进入 path;入口 icon 按设计保持空置。未知 entry、原型链键和任意 URL 均被白名单拒绝。
|
||||
|
||||
## 3. 权限与数据范围
|
||||
|
||||
所有移动市场接口同时应用:
|
||||
|
||||
1. 当前租户与软删除过滤;
|
||||
2. 当前租户有效直接角色与启用职位继承角色的 data scope 并集;
|
||||
3. 当前固定路由的现场范围。
|
||||
|
||||
现场范围为:
|
||||
|
||||
- 市场专员:单据采集信息 `sales_user_id = 当前用户`;
|
||||
- 地区经理:当前用户负责地区(`is_leader=1`)或单据 `region_manager_user_id = 当前用户`;
|
||||
- 兼岗用户进入哪套固定路由,就使用哪套现场范围,不做页面内并集。
|
||||
|
||||
详情和所有写操作均重新校验范围。涉及并发写入的样本办理、运费关联和批次头修改使用 MySQL current read、稳定锁顺序与锁后权限复核,避免 `REPEATABLE READ` 旧快照授权。
|
||||
|
||||
## 4. 六模块开放能力
|
||||
|
||||
| 模块 | 实际开放动作 | 明确不开放 |
|
||||
|---|---|---|
|
||||
| 预签约订单 | 列表、详情、新建、编辑、原子转正式 | 删除、财务确认;终态继续编辑/转换 |
|
||||
| 协议单 | 细胞/科研双来源列表、详情、新建、编辑 | 审核、作废、退款、财务动作、转换 |
|
||||
| 催缴 | 逾期未清与未来 30 天未清查询,跳转登记缴费 | 催缴编辑实体、发送/标记催缴 |
|
||||
| 缴费 | v2 样本缴费查看、登记/编辑草稿、提交财务确认、确认中撤回 | 财务确认/拒绝、删除、退款、开票、修改应缴计划 |
|
||||
| 样本 | 双来源进度查看;细胞储存证/保险轻量办理 | 科研样本写入、接收/质检/生产/入库状态、实验室码 |
|
||||
| 运费 | scope 内批次查看、新建、样本关联/解绑;own-only 批次头编辑 | 修改含范围外明细的共享批次头、查看隐藏明细/数量 |
|
||||
|
||||
后端返回 `allowedActions`,H5 再与模块静态白名单取交集。按钮隐藏不是授权手段,直达写接口重复执行状态和范围校验。
|
||||
|
||||
## 5. 运费脱敏
|
||||
|
||||
- 批次只要存在至少一条当前 scope 可见明细即可见;
|
||||
- 列表、关键词、来源筛选、total 和详情明细只基于可见行;
|
||||
- 响应只返回 `visibleItemCount`,不返回总关联数、隐藏数、共享标志或他人明细;
|
||||
- 创建、关联、解绑先完整校验全部样本,任一越权则整单回滚;
|
||||
- 样本已属于其他批次时返回冲突,不静默迁移;
|
||||
- 只有全部有效明细都在当前 scope 时才允许修改批次头;
|
||||
- 生成列与唯一索引保证同一样本最多存在一条有效运费关联。
|
||||
|
||||
## 6. 表单支撑与附件安全
|
||||
|
||||
移动表单 options 使用固定资源接口,不提供任意字典或全租户用户搜索。地区、医院和人员均按当前角色与业务单范围收敛;样本办理人员限定为该样本当前父单地区内的启用人员。
|
||||
|
||||
附件上传规则:
|
||||
|
||||
- 固定服务端目录,不接受客户端 folder;
|
||||
- 单文件非空且不超过 10 MiB;
|
||||
- 仅允许约定的图片、PDF 和 Office 类型,并校验扩展名、MIME 与文件结构;
|
||||
- HEIC/HEIF 严格校验 ISO-BMFF `ftyp` box 和品牌;
|
||||
- 暂存附件必须匹配当前租户、上传人、角色入口和模块;
|
||||
- 业务保存时批量锁定并在同一事务内绑定到唯一业务 ID;
|
||||
- 单字段最多 20 个引用、总长度不超过 4000;
|
||||
- 历史业务中已保存的 URL 可原样保留,但不能借此引入新外链;
|
||||
- 数据库提交前失败会回滚并清理对象存储文件。
|
||||
|
||||
本期未增加不安全的移动端附件下载接口。
|
||||
|
||||
## 7. 数据库迁移与部署顺序
|
||||
|
||||
建议按以下顺序执行:
|
||||
|
||||
1. `docs/sql/migrations/2026-10-10-market-field-scope-indexes.sql`
|
||||
2. `docs/sql/migrations/2026-10-10-presign-convert-unique.sql`
|
||||
3. `docs/sql/migrations/2026-10-11-market-payment-indexes.sql`
|
||||
4. `docs/sql/migrations/2026-10-11-market-sample-indexes.sql`
|
||||
5. `docs/sql/migrations/2026-10-11-market-freight-indexes.sql`
|
||||
6. `docs/sql/migrations/2026-10-10-market-mobile-menus.sql`
|
||||
|
||||
迁移前应先备份相关表,并处理脚本报告的历史重复预签转换、重复运费批次号或重复有效样本关联。菜单迁移后由管理员将十二个叶子权限分配给对应角色。
|
||||
|
||||
回滚采用数据库备份恢复或经审核的反向 DDL;不建议在未核对线上索引依赖时直接删除索引/生成列。菜单回滚应按迁移自然键删除新增叶子与空目录,不处理既有角色授权以外的数据。
|
||||
|
||||
## 8. 自动验证结果
|
||||
|
||||
2026-10-11 最终验证:
|
||||
|
||||
- 后端市场定向:`215 passed`;
|
||||
- 市场 SQL 静态测试:`11 passed`;
|
||||
- 后端全量:最初为 `912 passed, 4 failed`;后续已修复 CBC 测试契约和 3 项协议排序问题,复验为 `916 passed, 0 failed`;
|
||||
- Python `compileall`:通过;
|
||||
- 字段一致性:`0 errors, 5 warnings`,5 项为脚本既有实体告警;该脚本未注册本次 market DTO,本次字段白名单由 `test_market_*` 覆盖;
|
||||
- `mobile-h5`:`5/5 passed`;
|
||||
- `mobile-oa-h5`:`62/62 passed`;
|
||||
- `mobile-oa-h5` Vite 构建:通过,保留既有大 chunk 警告;
|
||||
- `git diff --check`:通过;
|
||||
- 后端知识图谱:`6122 nodes / 33427 edges / 40 communities`。
|
||||
|
||||
MySQL 验证使用隔离启动的 MySQL 5.7.24 临时实例,六份迁移连续执行两轮均成功。两个测试租户各得到 2 个目录和 12 个叶子,`sys_role_menu` 未增加;索引、生成列和重复数据预检符合预期。临时数据库验证后已删除。
|
||||
|
||||
## 9. 手工验收状态
|
||||
|
||||
自动化测试和构建已完成。以下环境相关项目尚未在本任务中执行,因此不声明通过:
|
||||
|
||||
- 375px、390px、430px 真实浏览器视口的逐页视觉检查;
|
||||
- 微信开发者工具中的 WebView、返回宿主、安全区和键盘体验;
|
||||
- 使用真实专员、经理、兼岗账号进行端到端菜单授权与操作验收;
|
||||
- 真实 MinIO、生产同构域名和登录 token 失效联调。
|
||||
|
||||
部署到测试环境后应按实施计划 Task 11 Step 4 的七项矩阵完成并留存截图/录屏。
|
||||
|
||||
## 10. 已知限制与后续事项
|
||||
|
||||
- 市场菜单正式 icon 尚未提供,当前按设计空置;
|
||||
- 缴费移动端明确支持 v2 样本缴费模型,v1 历史记录返回兼容提示;
|
||||
- 科研样本本期只读,因为现有模型没有不影响质检/入库状态的市场轻量办理实体;
|
||||
- 移动端附件下载未开放,避免复用旧后台的模糊 URL 查询;
|
||||
- Vite 主 chunk 仍有超过 500 kB 的既有警告,可后续单独做代码分包;
|
||||
- `mobile-h5` Node 测试有未声明 `type: module` 的重解析警告,不影响当前运行结果;
|
||||
- 字段一致性 5 个既有 warning 应另立任务处理;后端 4 个既有基线测试失败已于 2026-10-11 修复。
|
||||
@@ -0,0 +1,122 @@
|
||||
# 市场移动端附件展示与详情返回按钮调整设计
|
||||
|
||||
## 背景与目标
|
||||
|
||||
市场专员、地区经理的预签约订单、协议单、催缴信息、缴费信息、样本信息、运费信息已经采用移动端卡片布局。本次在不改变业务权限和接口语义的前提下,完成两项统一调整:
|
||||
|
||||
1. 所有市场业务详情页不再显示内容区左上角的圆形悬浮返回按钮。
|
||||
2. 市场业务的附件上传、编辑回显和详情展示采用工作流 `OaUploadField.vue` 的 84×84px 标准卡片视觉与交互。
|
||||
|
||||
经 HTML Demo 确认,采用方案 A「工作流标准卡片」。
|
||||
|
||||
## 范围
|
||||
|
||||
### 包含
|
||||
|
||||
- 市场专员和地区经理共 12 个市场入口涉及的详情页。
|
||||
- 协议附件、知情同意书、健康调查表、传染病报告、其他附件。
|
||||
- 预签约和缴费中的转账附件/转账凭证。
|
||||
- 样本储存证、保险等办理附件。
|
||||
- 后续通过同一市场表单字段类型 `upload` 接入的附件。
|
||||
|
||||
### 不包含
|
||||
|
||||
- 不修改工作流附件组件自身。
|
||||
- 不修改附件上传、下载接口协议。
|
||||
- 不执行对象存储物理删除;编辑态删除只移除当前表单中的附件引用,保存后由现有业务接口更新字段。
|
||||
- 不移除新增、编辑页为避免误退出而保留的返回能力。
|
||||
- 不改变列表页、权限判断和业务状态流转。
|
||||
|
||||
## 交互设计
|
||||
|
||||
### 详情页返回按钮
|
||||
|
||||
- `MarketModuleDetail.vue` 关闭 `MarketPageShell` 的返回入口。
|
||||
- 页面继续保持无顶部标题栏,不再渲染左上角圆形返回按钮,也不为该按钮预留顶部空白。
|
||||
- 用户返回由小程序/WebView 宿主导航承担。
|
||||
|
||||
### 附件卡片
|
||||
|
||||
- 单个附件卡片尺寸为 84×84px,圆角 14px,与工作流一致。
|
||||
- 图片附件使用 `object-fit: cover` 展示真实缩略图,底部渐变层显示截断后的文件名。
|
||||
- PDF、Excel、Word、压缩包和其他文件使用统一文档轮廓图标,通过红、绿、蓝、金、品牌绿区分类型。
|
||||
- 非图片卡片底部显示“点击下载”,不展示完整 URL 或对象存储路径。
|
||||
- 文件名从接口返回的 `fileName`/`name` 优先取得,缺失时从 URL 最后一段安全解码;界面只展示文件名。
|
||||
- 添加卡片作为附件行第一项,保持工作流的绿色渐变加号和虚线边框。
|
||||
|
||||
### 编辑态
|
||||
|
||||
- 选择文件后沿用现有 `uploadMarketAttachment` 立即上传。
|
||||
- 上传选择范围与后端一致:协议、缴费、样本单个文件不超过 10 MiB,预签约不超过 20 MiB;仅允许 JPG/JPEG、PNG、WebP、HEIC/HEIF、PDF、DOC/DOCX、XLS/XLSX。
|
||||
- 上传期间显示加载状态并禁止重复提交。
|
||||
- 已上传附件右上角显示删除按钮;删除更新当前字段值,附件立即从界面消失。
|
||||
- 图片点击打开图片预览;非图片点击调用现有 `downloadAttachmentFile` 下载。
|
||||
- 删除按钮视觉尺寸为 20px 左右,但实际点击热区不小于 44×44px。
|
||||
|
||||
### 详情只读态
|
||||
|
||||
- 隐藏添加卡片和删除按钮。
|
||||
- 图片仍支持预览,非图片仍支持下载。
|
||||
- 协议、缴费、样本、预签约等详情定义不再把逗号分隔 URL 拼成普通文本,而是输出结构化附件字段交给附件卡片组件渲染。
|
||||
|
||||
## 组件与数据设计
|
||||
|
||||
### 附件标准化工具
|
||||
|
||||
新增市场附件展示工具,统一处理以下输入:
|
||||
|
||||
- 逗号分隔 URL 字符串;
|
||||
- URL 字符串数组;
|
||||
- 包含 `url`、`fileUrl`、`name`、`fileName` 的对象数组。
|
||||
|
||||
标准输出包含 `url`、`name`、`explicitName`、`isImage` 和 `kind`,其中 `kind` 为 `pdf`、`xls`、`doc`、`zip` 或 `other`。仅当接口确实返回原始文件名时才把名称传给下载接口;URL 推导名只用于卡片显示,下载时由后端附件记录恢复原始文件名。
|
||||
|
||||
### 共享展示组件
|
||||
|
||||
新增一个市场附件卡片组件,职责仅包括:
|
||||
|
||||
- 渲染添加卡片、图片缩略图和文件图标;
|
||||
- 图片预览、文件下载;
|
||||
- 编辑态删除事件;
|
||||
- 只读态隐藏上传和删除。
|
||||
|
||||
`MarketFormField.vue` 与 `PresignAttachmentUploader.vue` 复用该展示规则,但保留各自现有上传请求和表单数据更新逻辑,避免扩大接口改造范围。
|
||||
|
||||
### 详情字段协议
|
||||
|
||||
`MarketDetailSection.vue` 支持附件字段类型:
|
||||
|
||||
```js
|
||||
{
|
||||
label: '协议附件',
|
||||
type: 'attachments',
|
||||
value: 'url-a,url-b'
|
||||
}
|
||||
```
|
||||
|
||||
嵌套卡片字段同样支持该类型,用于缴费转账、预签转账和样本办理附件。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 附件 URL 为空时不渲染无效卡片。
|
||||
- 图片加载失败时降级为普通文件图标,避免出现破损图片。
|
||||
- 下载失败沿用统一 Toast 提示,不直接跳转或暴露路径。
|
||||
- 上传失败保持现有错误提示,不向表单写入无效 URL。
|
||||
- 删除只更新本地表单状态,不发起未经授权的物理删除请求。
|
||||
|
||||
## 测试策略
|
||||
|
||||
1. 组件契约测试:市场详情页不再渲染悬浮返回按钮。
|
||||
2. 工具单元测试:字符串、数组、对象附件输入均能正确标准化;图片和文件类型识别正确。
|
||||
3. 组件契约测试:图片使用缩略图,非图片使用标准图标,并具备下载和编辑态删除能力。
|
||||
4. 模块契约测试:协议、缴费、样本、预签约详情把附件声明为 `attachments` 类型,不再展示原始路径。
|
||||
5. 回归测试:市场移动端全部 Node 测试通过。
|
||||
6. 构建验证:`pnpm build` 通过。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 12 个市场入口的详情页均无左上角圆形返回按钮。
|
||||
- 任意附件字段都不显示完整 URL 或对象存储路径。
|
||||
- 图片显示缩略图,非图片显示与工作流一致的文件类型图标。
|
||||
- 编辑态支持立即上传和删除;详情态支持图片预览和文件下载。
|
||||
- 附件卡片在窄屏下自动换行,无横向溢出。
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不在登录页或其他业务页显示标识。
|
||||
- 不做 sticky 吸顶。
|
||||
- 不在除首页、登录页以外的业务页显示标识。
|
||||
- 首页色条不做 sticky 吸顶。
|
||||
- 不改造微信上传/发布流程,不增加 CI 门禁。
|
||||
- 不自动改回 `APP_ENV`(仍由开发者手动切换)。
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
|
||||
| 项 | 约定 |
|
||||
| --- | --- |
|
||||
| 出现页面 | 仅首页 `pages/index/index.vue` |
|
||||
| 位置 | 绿头 Header **之上**,随页面滚动(非 sticky) |
|
||||
| 出现页面 | 首页 `pages/index/index.vue`;登录页 `pages/login/index.vue` |
|
||||
| 位置 | 首页:绿头 Header **之上**(非 sticky);登录页:**底部贴底**(含安全区) |
|
||||
| local 文案 | `本地环境` |
|
||||
| test 文案 | `测试环境` |
|
||||
| local 配色 | 背景 `#f59e0b`,文字深琥珀(如 `#451a03`) |
|
||||
@@ -67,7 +67,7 @@ export const ENV_BANNER = {
|
||||
1. `APP_ENV='local'`:打开首页可见橙色条,文案为「本地环境」。
|
||||
2. `APP_ENV='test'`:打开首页可见蓝色条,文案为「测试环境」。
|
||||
3. `APP_ENV='prod'`:首页 DOM/视觉中无环境色条。
|
||||
4. 进入其他页面(登录、OA 列表等)无此色条。
|
||||
4. 登录页底部可见同文案色条;进入其他业务页(如 OA 列表)无此色条。
|
||||
5. 切换 `APP_ENV` 后重新编译/刷新,首页展示与配置一致。
|
||||
|
||||
## 测试建议
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# 预签约订单协议编号自动发号 — 设计说明
|
||||
|
||||
**日期:** 2026-10-12
|
||||
**状态:** 已确认
|
||||
**范围:** Web 管理端预签约、市场移动端(微信小程序 WebView / `mobile-oa-h5`)、后端发号与唯一性
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
预签约订单「协议编号」当前为可选手填;未填时列表显示 `-`,仅在转正式协议时才可能自动生成。业务需要:
|
||||
|
||||
1. **新增保存时**自动生成协议编号(与正式采集协议同一发号规则)。
|
||||
2. **编辑不可改号**;历史空号在**编辑保存时补发**。
|
||||
3. **新增/编辑弹窗不再展示**该字段;列表仍展示并可搜索。
|
||||
4. **租户内全局唯一**:采集协议号、科研捐赠号、预签约协议号互斥占用。
|
||||
5. **转正式**直接使用预签订单上已有编号。
|
||||
|
||||
## 2. 决策摘要
|
||||
|
||||
| 项 | 决策 |
|
||||
|----|------|
|
||||
| 发号时机 | 新增保存必发;编辑保存仅空号补发 |
|
||||
| 客户端改号 | 忽略请求体中的 `protocolNo` / `protocol_no` |
|
||||
| 发号实现 | 复用 `allocate_next_biz_no(kind="collection")`(`XBCC` + `YYYYMMDD` + 6 位序号) |
|
||||
| 唯一性 | 扩展 `assert_biz_no_available`,纳入未删除预签约 `protocol_no` |
|
||||
| UI | Web / 移动端表单隐藏字段;列表保留 |
|
||||
| 转正式 | 继续 `protocol_no=order.protocol_no or ""`;空号兜底发号不变 |
|
||||
|
||||
## 3. 后端行为
|
||||
|
||||
### 3.1 创建 `create_presign_order`
|
||||
|
||||
- 不读取客户端协议编号。
|
||||
- 在写入前:`protocol_no = allocate_next_biz_no(..., kind="collection")`(已含唯一性断言)。
|
||||
- 保证创建成功后订单必有非空 `protocol_no`。
|
||||
|
||||
### 3.2 更新 `update_presign_order`
|
||||
|
||||
- 从可写 mapping 中移除 `protocol_no` / `protocolNo`。
|
||||
- 若当前 `obj.protocol_no` 为空(或仅空白):补发号并写回。
|
||||
- 若已有非空号:保持不变。
|
||||
|
||||
### 3.3 唯一性 `assert_biz_no_available`
|
||||
|
||||
在现有「采集协议 + 科研捐赠」校验之外:
|
||||
|
||||
- 查询同租户、`del_flag=0` 且 `protocol_no` 相等的预签约订单。
|
||||
- 新增参数 `exclude_presign_order_id`:更新/转正式时排除自身。
|
||||
- 冲突时抛出与现有一致风格的 `ValueError`(如「协议编号已存在」)。
|
||||
|
||||
发号路径(`allocate_next_biz_no`)与转正式创建协议路径共用该函数,从而预占号不会被正式单或其它预签单占用。
|
||||
|
||||
### 3.4 转正式 `convert_to_protocol`
|
||||
|
||||
保持现逻辑:优先用订单号;空号时由 `create_collection_protocol` 发号并回写预签订单。新建单不应再走空号分支。
|
||||
|
||||
## 4. 前端
|
||||
|
||||
### 4.1 Web(`web-admin`)
|
||||
|
||||
- `PresignOrderFormDialog.vue`:移除「协议编号」表单项;提交 payload 不再传 `protocolNo`。
|
||||
- 列表页:保留「协议编号」列与搜索关键字。
|
||||
|
||||
### 4.2 移动端 / 小程序 WebView(`mobile-oa-h5`)
|
||||
|
||||
- `PresignOrderForm.vue`:移除协议编号输入。
|
||||
- `presign-form-state.js` 及契约测试:表单字段列表去掉可编辑的 `protocolNo`(详情/列表展示仍可用接口返回值)。
|
||||
- 列表卡片继续展示协议编号。
|
||||
|
||||
## 5. 测试要点
|
||||
|
||||
- 创建预签:返回非空 `protocolNo`;客户端传入自定义号被忽略。
|
||||
- 更新:已有号不被客户端篡改;空号更新后补齐。
|
||||
- `assert_biz_no_available`:预签已占号时,正式协议/捐赠/另一预签均不可再用同一号。
|
||||
- 转正式:使用预签上已有号,正式协议 `protocolNo` 一致。
|
||||
- 前端契约:表单视图字段不含协议编号输入。
|
||||
|
||||
## 6. 非目标
|
||||
|
||||
- 不为历史未编辑空号单做离线批量补号脚本(依赖编辑保存或转正式兜底)。
|
||||
- 不改发号格式前缀/位数。
|
||||
- 不在 DB 层新增唯一索引(软删除下多 NULL 与历史空号使强制唯一索引复杂;以应用层校验为准,与正式协议现状一致)。
|
||||
@@ -88,7 +88,7 @@ page-head → panel → filter-block → list-toolbar → list-table → paginat
|
||||
| 业务 | Hash | 源码 | 后端 API |
|
||||
|------|------|------|----------|
|
||||
| Demo 模板 | `/#/demo/unit` | `views/system/unit/index.vue` | `admin.py` item-units |
|
||||
| 客户管理 | `/#/basic-info/customer` | `views/basic-info/customer/index.vue` | `basic_info.py` customers |
|
||||
| 客户基础信息 | `/#/basic-info/customer` | `views/basic-info/customer/index.vue` | `basic_info.py` customers |
|
||||
| 耗材管理 | `/#/production/consumable` | `views/production/consumable/index.vue` | `production.py` consumables |
|
||||
| 快递公司 | `/#/system/express` | `views/system/express/index.vue` | `admin.py` |
|
||||
| 洁净室 | `/#/operations/clean-room` | `views/operations/clean-room/index.vue` | `operations.py` |
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
v-for="m in menus"
|
||||
:key="m.id"
|
||||
class="menu-card"
|
||||
:class="{ 'menu-card--text-only': !m.icon }"
|
||||
@click="$emit('select', m)"
|
||||
>
|
||||
<view
|
||||
v-if="m.icon"
|
||||
class="menu-icon"
|
||||
:class="[toneClass(m), plainClass(m)]"
|
||||
:style="iconBoxStyle(m)"
|
||||
@@ -14,6 +16,10 @@
|
||||
<VanIcon :name="m.icon" :color="iconFg(m)" :size="iconSize(m)" />
|
||||
<text v-if="badgeText(m)" class="menu-badge">{{ badgeText(m) }}</text>
|
||||
</view>
|
||||
<text
|
||||
v-if="showGroupTitle && m.groupTitle"
|
||||
class="menu-group-title"
|
||||
>{{ m.groupTitle }}</text>
|
||||
<text class="menu-title">{{ m.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -28,7 +34,9 @@
|
||||
name: 'MenuGrid',
|
||||
components: { VanIcon },
|
||||
props: {
|
||||
menus: { type: Array, default: () => [] }
|
||||
menus: { type: Array, default: () => [] },
|
||||
/** 分区标题栏展示区域名时,卡片内不再重复 groupTitle 胶囊 */
|
||||
showGroupTitle: { type: Boolean, default: true }
|
||||
},
|
||||
emits: ['select'],
|
||||
methods: {
|
||||
|
||||
@@ -56,7 +56,12 @@
|
||||
'oa-mine': '/static/menu-icons/oa-mine.png',
|
||||
'oa-cc': '/static/menu-icons/oa-cc.png',
|
||||
'oa-mentions': '/static/menu-icons/oa-mentions.png',
|
||||
'oa-wechat-notify': '/static/menu-icons/oa-wechat-notify.png'
|
||||
'oa-wechat-notify': '/static/menu-icons/oa-wechat-notify.png',
|
||||
'market-presign': '/static/menu-icons/market-presign.png',
|
||||
'market-protocol': '/static/menu-icons/market-protocol.png',
|
||||
'market-payment-chase': '/static/menu-icons/market-payment-chase.png',
|
||||
'market-payment': '/static/menu-icons/market-payment.png',
|
||||
'market-freight': '/static/menu-icons/market-freight.png'
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -95,6 +95,14 @@
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/market/webview/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "市场业务",
|
||||
"navigationBarBackgroundColor": "#ecfdf5",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/oa/drafts/index",
|
||||
"style": {
|
||||
|
||||
@@ -64,12 +64,8 @@
|
||||
</swiper>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="panel-hd">
|
||||
<text class="panel-title">功能菜单</text>
|
||||
<text class="hint">{{ menusHint }}</text>
|
||||
</view>
|
||||
<view v-if="menusLoading" class="home-menus-loading">
|
||||
<view v-if="menusLoading" class="panel">
|
||||
<view class="home-menus-loading">
|
||||
<view class="pulse-dots" aria-hidden="true">
|
||||
<view class="pulse-dot" />
|
||||
<view class="pulse-dot" />
|
||||
@@ -77,13 +73,34 @@
|
||||
</view>
|
||||
<text class="home-menus-loading__text">加载菜单…</text>
|
||||
</view>
|
||||
<MenuGrid
|
||||
v-else-if="menus.length"
|
||||
:menus="menus"
|
||||
@select="onMenuSelect"
|
||||
/>
|
||||
</view>
|
||||
<view v-else-if="menuRegions.length" class="menu-regions">
|
||||
<view
|
||||
v-for="region in menuRegions"
|
||||
:key="region.key"
|
||||
class="menu-region"
|
||||
:class="'menu-region--' + region.tone"
|
||||
>
|
||||
<view class="menu-region__hd">
|
||||
<view class="menu-region__left">
|
||||
<view class="menu-region__mark" />
|
||||
<text class="menu-region__title">{{ region.title }}</text>
|
||||
</view>
|
||||
<text v-if="region.hint" class="menu-region__hint">{{
|
||||
region.hint
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="menu-region__bd">
|
||||
<MenuGrid
|
||||
:menus="region.menus"
|
||||
:show-group-title="false"
|
||||
@select="onMenuSelect"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="panel">
|
||||
<VanEmpty
|
||||
v-else
|
||||
description="暂无可用菜单权限"
|
||||
tip="请联系管理员开通功能入口"
|
||||
/>
|
||||
@@ -102,6 +119,7 @@
|
||||
getUser
|
||||
} from '../../utils/auth.js'
|
||||
import { fetchMyMobileMenus } from '../../utils/menu.js'
|
||||
import { groupMenusByRegion } from '../../utils/menu-regions.js'
|
||||
import {
|
||||
formatOaBadgeCount,
|
||||
refreshOaMenuBadges
|
||||
@@ -114,6 +132,7 @@
|
||||
{
|
||||
id: 'oa-start',
|
||||
title: '发起审批',
|
||||
groupTitle: 'OA工作流',
|
||||
icon: 'approve',
|
||||
path: '/pages/oa/start/index',
|
||||
iconBg: 'transparent'
|
||||
@@ -149,9 +168,8 @@
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
menusHint() {
|
||||
if (this.menusLoading) return '加载中'
|
||||
return `共 ${this.menus.length} 项`
|
||||
menuRegions() {
|
||||
return groupMenusByRegion(this.menus)
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
|
||||
@@ -123,10 +123,19 @@
|
||||
</text>
|
||||
<text>。是否同意并继续登录?</text>
|
||||
</ConfirmDialog>
|
||||
|
||||
<view
|
||||
v-if="envBanner.show"
|
||||
class="env-banner"
|
||||
:class="'env-banner--' + envBanner.tone"
|
||||
>
|
||||
<text class="env-banner__text">{{ envBanner.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ENV_BANNER } from '../../utils/config.js'
|
||||
import {
|
||||
isLoggedIn,
|
||||
passwordLogin,
|
||||
@@ -143,6 +152,7 @@
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
envBanner: ENV_BANNER,
|
||||
username: '',
|
||||
password: '',
|
||||
showPwd: false,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<web-view v-if="src" :src="src" @message="onMessage" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { forceRelogin, getToken, silentWechatLogin } from '../../../utils/auth.js'
|
||||
import {
|
||||
buildMarketH5Url,
|
||||
canOpenMarketWebview,
|
||||
isMarketCloseAction,
|
||||
isTrustedMarketCloseMessage,
|
||||
resolveMarketBackFallback,
|
||||
resolveMarketEntry,
|
||||
resolveMarketReturnPath
|
||||
} from '../../../utils/market-h5.js'
|
||||
import { navigateBackFromOaHost } from '../../../utils/oa-navigate-back.js'
|
||||
|
||||
function buildHostPath(entry, returnPath) {
|
||||
const parts = ['entry=' + encodeURIComponent(entry)]
|
||||
if (returnPath) {
|
||||
parts.push('returnPath=' + encodeURIComponent(returnPath))
|
||||
}
|
||||
return '/pages/market/webview/index?' + parts.join('&')
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
src: '',
|
||||
_closing: false,
|
||||
_unloaded: false,
|
||||
_returnPath: '/pages/index/index'
|
||||
}
|
||||
},
|
||||
async onLoad(query) {
|
||||
this._unloaded = false
|
||||
const entryKey = String((query && query.entry) || '').trim()
|
||||
const entry = resolveMarketEntry(entryKey)
|
||||
if (!entry) {
|
||||
uni.showToast({ title: '入口无效', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}, 400)
|
||||
return
|
||||
}
|
||||
|
||||
const returnPath = resolveMarketReturnPath(query)
|
||||
this._returnPath = returnPath
|
||||
if (!getToken()) {
|
||||
try {
|
||||
await silentWechatLogin()
|
||||
} catch (_) {
|
||||
if (this._unloaded) return
|
||||
forceRelogin('请先登录', buildHostPath(entryKey, returnPath))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (!canOpenMarketWebview(this._unloaded, token)) {
|
||||
if (this._unloaded) return
|
||||
forceRelogin('请先登录', buildHostPath(entryKey, returnPath))
|
||||
return
|
||||
}
|
||||
|
||||
uni.setNavigationBarTitle({ title: entry.title })
|
||||
this.src = buildMarketH5Url({ entry: entryKey, token, returnPath })
|
||||
|
||||
// #ifdef H5
|
||||
this._onParentMessage = (event) => {
|
||||
const frameWindow = this.resolveMarketFrameWindow()
|
||||
if (!isTrustedMarketCloseMessage(event, frameWindow)) return
|
||||
this.navigateBackFromMarket(event.data)
|
||||
}
|
||||
window.addEventListener('message', this._onParentMessage)
|
||||
// #endif
|
||||
},
|
||||
onUnload() {
|
||||
this._unloaded = true
|
||||
// #ifdef H5
|
||||
if (this._onParentMessage) {
|
||||
window.removeEventListener('message', this._onParentMessage)
|
||||
this._onParentMessage = null
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
resolveMarketFrameWindow() {
|
||||
// #ifdef H5
|
||||
try {
|
||||
const iframe = document.querySelector(
|
||||
'uni-web-view iframe, web-view iframe'
|
||||
)
|
||||
if (iframe && iframe.contentWindow) return iframe.contentWindow
|
||||
if (window.frames && window.frames.length === 1) {
|
||||
return window.frames[0]
|
||||
}
|
||||
} catch (_) {
|
||||
/* origin 校验仍会生效 */
|
||||
}
|
||||
// #endif
|
||||
return null
|
||||
},
|
||||
navigateBackFromMarket(payload) {
|
||||
if (this._closing) return
|
||||
const action = String((payload && payload.action) || '')
|
||||
if (!isMarketCloseAction(action)) return
|
||||
this._closing = true
|
||||
navigateBackFromOaHost({
|
||||
fallbackUrl: resolveMarketBackFallback(this._returnPath),
|
||||
onStuck: () => {
|
||||
this._closing = false
|
||||
}
|
||||
})
|
||||
},
|
||||
onMessage(event) {
|
||||
console.log('[market webview] @message', event && event.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1,10 +1,47 @@
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 320rpx 48rpx 64rpx;
|
||||
padding: 320rpx 48rpx 0;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.login-page > .login-wechat,
|
||||
.login-page > button.login-wechat {
|
||||
margin-bottom: 64rpx;
|
||||
}
|
||||
|
||||
.login-page .env-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
margin-top: auto;
|
||||
margin-left: -48rpx;
|
||||
margin-right: -48rpx;
|
||||
width: auto;
|
||||
padding: 12rpx 24rpx calc(12rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-page .env-banner__text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.login-page .env-banner--local {
|
||||
background: #f59e0b;
|
||||
color: #451a03;
|
||||
}
|
||||
|
||||
.login-page .env-banner--test {
|
||||
background: #3b82f6;
|
||||
color: #eff6ff;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 44rpx;
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
@import '../mixins.scss';
|
||||
|
||||
/* 首页分区菜单:区域卡片 + 色带标题栏 */
|
||||
.menu-regions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.menu-region {
|
||||
overflow: hidden;
|
||||
background: var(--surface, #fff);
|
||||
border: 1rpx solid rgba(220, 235, 228, 0.95);
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 8rpx 28rpx rgba(15, 41, 30, 0.06);
|
||||
}
|
||||
|
||||
.menu-region__hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 22rpx 28rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-region__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menu-region__mark {
|
||||
width: 8rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-region__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.menu-region__hint {
|
||||
font-size: 22rpx;
|
||||
opacity: 0.92;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.menu-region--market .menu-region__hd {
|
||||
background: #ea9518;
|
||||
}
|
||||
|
||||
.menu-region--oa .menu-region__hd {
|
||||
background: #10aa1a;
|
||||
}
|
||||
|
||||
.menu-region--other .menu-region__hd {
|
||||
background: #5f7a6d;
|
||||
}
|
||||
|
||||
.menu-region__bd {
|
||||
padding: 24rpx 16rpx 20rpx;
|
||||
}
|
||||
|
||||
.menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -21,6 +90,12 @@
|
||||
background: var(--brand-green-soft, #ecfdf5);
|
||||
}
|
||||
|
||||
.menu-card--text-only {
|
||||
justify-content: center;
|
||||
min-height: 132rpx;
|
||||
padding-top: 16rpx;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
@@ -98,3 +173,15 @@
|
||||
max-width: 144rpx;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.menu-group-title {
|
||||
max-width: 144rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border: 1rpx solid rgba(10, 159, 109, 0.22);
|
||||
border-radius: 999rpx;
|
||||
background: rgba(10, 159, 109, 0.08);
|
||||
color: #0a9f6d;
|
||||
font-size: 20rpx;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { OA_H5_ORIGIN } from './config.js'
|
||||
|
||||
const MODULES = [
|
||||
['presign', '预签约订单'],
|
||||
['protocol', '协议单'],
|
||||
['payment-chase', '催缴信息'],
|
||||
['payment', '缴费信息'],
|
||||
['freight', '运费信息']
|
||||
]
|
||||
|
||||
const ROLE_GROUPS = [
|
||||
['specialist', '专员'],
|
||||
['manager', '经理']
|
||||
]
|
||||
|
||||
const MARKET_CLOSE_ACTIONS = new Set(['market-close', 'market-done'])
|
||||
|
||||
/**
|
||||
* 从绝对地址解析 origin(scheme + host[:port])。
|
||||
* 微信小程序运行时没有可用的 URL 构造函数,不能用 new URL().origin。
|
||||
*/
|
||||
export function resolveHttpOrigin(value) {
|
||||
const match = String(value || '')
|
||||
.trim()
|
||||
.match(/^(https?:\/\/[^/?#]+)/i)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
const MARKET_H5_ORIGIN = resolveHttpOrigin(OA_H5_ORIGIN)
|
||||
|
||||
export const MARKET_ENTRIES = Object.freeze(
|
||||
Object.fromEntries(
|
||||
ROLE_GROUPS.flatMap(([role, roleLabel]) =>
|
||||
MODULES.map(([module, title]) => [
|
||||
role + '-' + module,
|
||||
Object.freeze({
|
||||
path: '/market/' + role + '/' + module,
|
||||
title: title + '(' + roleLabel + ')',
|
||||
groupTitle: '市场部'
|
||||
})
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
function trimTrailingSlash(value) {
|
||||
return String(value || '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function isSafeMiniProgramPath(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw || raw.includes('\\') || raw.includes('#')) return false
|
||||
let decoded = raw
|
||||
try {
|
||||
decoded = decodeURIComponent(raw)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
const pathname = decoded.split('?')[0]
|
||||
if (!/^\/pages\/[A-Za-z0-9_./-]+$/.test(pathname)) return false
|
||||
return pathname.split('/').every((part) => part !== '.' && part !== '..')
|
||||
}
|
||||
|
||||
export function resolveMarketEntry(entry) {
|
||||
const key = String(entry || '').trim()
|
||||
return Object.prototype.hasOwnProperty.call(MARKET_ENTRIES, key)
|
||||
? MARKET_ENTRIES[key]
|
||||
: null
|
||||
}
|
||||
|
||||
export function resolveMarketReturnPath(query) {
|
||||
const fromQuery = String((query && query.returnPath) || '').trim()
|
||||
if (isSafeMiniProgramPath(fromQuery)) return fromQuery
|
||||
try {
|
||||
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
|
||||
if (pages && pages.length >= 2) {
|
||||
const previous = pages[pages.length - 2]
|
||||
const route =
|
||||
'/' + String((previous && previous.route) || '').replace(/^\//, '')
|
||||
if (isSafeMiniProgramPath(route)) return route
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
return '/pages/index/index'
|
||||
}
|
||||
|
||||
export function resolveMarketBackFallback(returnPath) {
|
||||
return isSafeMiniProgramPath(returnPath)
|
||||
? String(returnPath).trim()
|
||||
: '/pages/index/index'
|
||||
}
|
||||
|
||||
export function isMarketCloseAction(action) {
|
||||
const value = String(action || '').trim()
|
||||
return value !== '' && MARKET_CLOSE_ACTIONS.has(value)
|
||||
}
|
||||
|
||||
export function isTrustedMarketCloseMessage(event, expectedWindow) {
|
||||
if (!event || event.origin !== MARKET_H5_ORIGIN) return false
|
||||
if (expectedWindow && event.source !== expectedWindow) return false
|
||||
const payload = event.data
|
||||
return (
|
||||
!!payload &&
|
||||
payload.source === 'zkxw-oa-h5' &&
|
||||
isMarketCloseAction(payload.action)
|
||||
)
|
||||
}
|
||||
|
||||
export function canOpenMarketWebview(unloaded, token) {
|
||||
return !unloaded && String(token || '').trim() !== ''
|
||||
}
|
||||
|
||||
export function buildMarketH5Url({ entry, token, returnPath } = {}) {
|
||||
const resolved = resolveMarketEntry(entry)
|
||||
if (!resolved) return ''
|
||||
const params = []
|
||||
const authToken = String(token || '').trim()
|
||||
if (authToken) {
|
||||
params.push('token=' + encodeURIComponent(authToken))
|
||||
}
|
||||
const safeReturnPath = resolveMarketBackFallback(returnPath)
|
||||
params.push('returnPath=' + encodeURIComponent(safeReturnPath))
|
||||
return (
|
||||
trimTrailingSlash(OA_H5_ORIGIN) +
|
||||
resolved.path +
|
||||
'?' +
|
||||
params.join('&')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { OA_H5_ORIGIN } from './config.js'
|
||||
import {
|
||||
MARKET_ENTRIES,
|
||||
buildMarketH5Url,
|
||||
canOpenMarketWebview,
|
||||
isTrustedMarketCloseMessage,
|
||||
resolveHttpOrigin,
|
||||
resolveMarketBackFallback,
|
||||
resolveMarketEntry,
|
||||
resolveMarketReturnPath
|
||||
} from './market-h5.js'
|
||||
import { normalizeMenu } from './menu.js'
|
||||
import { isOaHostWebviewRoute } from './oa-navigate-back.js'
|
||||
|
||||
const marketOrigin = resolveHttpOrigin(OA_H5_ORIGIN)
|
||||
const marketBase = String(OA_H5_ORIGIN || '').replace(/\/$/, '')
|
||||
|
||||
assert.equal(resolveHttpOrigin('https://xwyd.zkxw.cn/oa-h5'), 'https://xwyd.zkxw.cn')
|
||||
assert.equal(
|
||||
resolveHttpOrigin('http://127.0.0.1:5178/oa-h5/'),
|
||||
'http://127.0.0.1:5178'
|
||||
)
|
||||
assert.equal(resolveHttpOrigin(''), '')
|
||||
assert.equal(resolveHttpOrigin('not-a-url'), '')
|
||||
assert.ok(marketOrigin)
|
||||
|
||||
const expectedEntries = [
|
||||
'specialist-presign',
|
||||
'specialist-protocol',
|
||||
'specialist-payment-chase',
|
||||
'specialist-payment',
|
||||
'specialist-freight',
|
||||
'manager-presign',
|
||||
'manager-protocol',
|
||||
'manager-payment-chase',
|
||||
'manager-payment',
|
||||
'manager-freight'
|
||||
]
|
||||
|
||||
assert.deepEqual(Object.keys(MARKET_ENTRIES), expectedEntries)
|
||||
assert.equal(MARKET_ENTRIES['specialist-presign'].title, '预签约订单(专员)')
|
||||
assert.equal(MARKET_ENTRIES['specialist-presign'].groupTitle, '市场部')
|
||||
assert.equal(MARKET_ENTRIES['manager-freight'].title, '运费信息(经理)')
|
||||
assert.equal(MARKET_ENTRIES['manager-freight'].groupTitle, '市场部')
|
||||
assert.equal(
|
||||
resolveMarketEntry('specialist-presign').path,
|
||||
'/market/specialist/presign'
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarketEntry('manager-freight').path,
|
||||
'/market/manager/freight'
|
||||
)
|
||||
assert.equal(resolveMarketEntry('https://evil.example'), null)
|
||||
assert.equal(resolveMarketEntry('../manager/freight'), null)
|
||||
assert.equal(resolveMarketEntry('specialist-presign?scope=manager'), null)
|
||||
|
||||
assert.equal(
|
||||
resolveMarketReturnPath({ returnPath: '/pages/index/index?tab=market' }),
|
||||
'/pages/index/index?tab=market'
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarketReturnPath({ returnPath: 'https://evil.example/callback' }),
|
||||
'/pages/index/index'
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarketReturnPath({ returnPath: '/pages/../evil' }),
|
||||
'/pages/index/index'
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarketBackFallback('/pages/index/index?tab=market'),
|
||||
'/pages/index/index?tab=market'
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarketBackFallback('https://evil.example/callback'),
|
||||
'/pages/index/index'
|
||||
)
|
||||
|
||||
const marketUrl = buildMarketH5Url({
|
||||
entry: 'manager-payment-chase',
|
||||
token: 'token +&',
|
||||
returnPath: '/pages/index/index?tab=market'
|
||||
})
|
||||
assert.equal(
|
||||
marketUrl,
|
||||
marketBase +
|
||||
'/market/manager/payment-chase' +
|
||||
'?token=token%20%2B%26&returnPath=%2Fpages%2Findex%2Findex%3Ftab%3Dmarket'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketH5Url({
|
||||
entry: 'https://evil.example',
|
||||
token: 'token',
|
||||
returnPath: '/pages/index/index'
|
||||
}),
|
||||
''
|
||||
)
|
||||
|
||||
assert.equal(normalizeMenu({ icon: '' }).icon, '')
|
||||
assert.equal(
|
||||
normalizeMenu({
|
||||
path: '/pages/market/webview/index?entry=specialist-presign',
|
||||
icon: ''
|
||||
}).icon,
|
||||
'market-presign'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeMenu({
|
||||
path: 'pages/market/webview/index?entry=manager-payment-chase',
|
||||
icon: ''
|
||||
}).icon,
|
||||
'market-payment-chase'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeMenu({
|
||||
path: '/pages/market/webview/index?entry=specialist-payment',
|
||||
icon: ''
|
||||
}).icon,
|
||||
'market-payment'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeMenu({ groupTitle: '市场部', icon: '' }).groupTitle,
|
||||
'市场部'
|
||||
)
|
||||
assert.equal(isOaHostWebviewRoute('pages/market/webview/index'), true)
|
||||
assert.equal(isOaHostWebviewRoute('pages/index/index'), false)
|
||||
|
||||
const marketFrame = {}
|
||||
const trustedMessage = {
|
||||
origin: marketOrigin,
|
||||
source: marketFrame,
|
||||
data: { source: 'zkxw-oa-h5', action: 'market-close' }
|
||||
}
|
||||
assert.equal(isTrustedMarketCloseMessage(trustedMessage, marketFrame), true)
|
||||
assert.equal(
|
||||
isTrustedMarketCloseMessage(
|
||||
{ ...trustedMessage, origin: 'https://evil.example' },
|
||||
marketFrame
|
||||
),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isTrustedMarketCloseMessage(
|
||||
{ ...trustedMessage, source: {} },
|
||||
marketFrame
|
||||
),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isTrustedMarketCloseMessage(
|
||||
{ ...trustedMessage, data: { source: 'zkxw-oa-h5', action: '' } },
|
||||
marketFrame
|
||||
),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
isTrustedMarketCloseMessage(
|
||||
{
|
||||
...trustedMessage,
|
||||
data: { source: 'zkxw-oa-h5', action: 'oa-detail-close' }
|
||||
},
|
||||
marketFrame
|
||||
),
|
||||
false
|
||||
)
|
||||
assert.equal(canOpenMarketWebview(false, 'token'), true)
|
||||
assert.equal(canOpenMarketWebview(true, 'token'), false)
|
||||
assert.equal(canOpenMarketWebview(false, ''), false)
|
||||
|
||||
console.log('market-h5: ok')
|
||||
@@ -2,7 +2,7 @@
|
||||
* 主页宫格图标配色:每项可自定义 iconBg / iconFg。
|
||||
* 未配置时按 icon 名回退到此处默认色对。
|
||||
*/
|
||||
/** OA 宫格 PNG 自带颜色,不套色底 */
|
||||
/** OA / 市场宫格 PNG 自带颜色,不套色底 */
|
||||
const OA_PNG_ICONS = new Set([
|
||||
'approve',
|
||||
'oa-drafts',
|
||||
@@ -11,7 +11,12 @@ const OA_PNG_ICONS = new Set([
|
||||
'oa-mine',
|
||||
'oa-cc',
|
||||
'oa-mentions',
|
||||
'oa-wechat-notify'
|
||||
'oa-wechat-notify',
|
||||
'market-presign',
|
||||
'market-protocol',
|
||||
'market-payment-chase',
|
||||
'market-payment',
|
||||
'market-freight'
|
||||
])
|
||||
|
||||
export const MENU_ICON_COLOR_DEFAULTS = {
|
||||
@@ -23,6 +28,11 @@ export const MENU_ICON_COLOR_DEFAULTS = {
|
||||
'oa-cc': { iconBg: 'transparent', iconFg: '' },
|
||||
'oa-mentions': { iconBg: 'transparent', iconFg: '' },
|
||||
'oa-wechat-notify': { iconBg: 'transparent', iconFg: '' },
|
||||
'market-presign': { iconBg: 'transparent', iconFg: '' },
|
||||
'market-protocol': { iconBg: 'transparent', iconFg: '' },
|
||||
'market-payment-chase': { iconBg: 'transparent', iconFg: '' },
|
||||
'market-payment': { iconBg: 'transparent', iconFg: '' },
|
||||
'market-freight': { iconBg: 'transparent', iconFg: '' },
|
||||
'orders-o': { iconBg: '#eef2f1', iconFg: '#3d524a' },
|
||||
'apps-o': { iconBg: '#eef2f1', iconFg: '#3d524a' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/** 已知区域:标题 → 展示元数据(顺序即首页优先展示序) */
|
||||
const REGION_DEFS = [
|
||||
{
|
||||
match: '市场部',
|
||||
key: 'market',
|
||||
title: '市场部',
|
||||
hint: '存储现场',
|
||||
tone: 'market'
|
||||
},
|
||||
{
|
||||
match: 'OA工作流',
|
||||
key: 'oa',
|
||||
title: 'OA工作流',
|
||||
hint: '审批协同',
|
||||
tone: 'oa'
|
||||
}
|
||||
]
|
||||
|
||||
const OTHER = {
|
||||
key: 'other',
|
||||
title: '其他',
|
||||
hint: '',
|
||||
tone: 'other'
|
||||
}
|
||||
|
||||
export function resolveRegionMeta(groupTitle) {
|
||||
const name = String(groupTitle || '').trim()
|
||||
if (!name) return { ...OTHER }
|
||||
const found = REGION_DEFS.find((d) => d.match === name)
|
||||
if (found) {
|
||||
return {
|
||||
key: found.key,
|
||||
title: found.title,
|
||||
hint: found.hint,
|
||||
tone: found.tone
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `custom:${name}`,
|
||||
title: name,
|
||||
hint: '',
|
||||
tone: 'other'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 groupTitle 聚合成区域;已知区域按 REGION_DEFS 顺序,其余按首次出现顺序排在后面。
|
||||
* @param {Array<{ groupTitle?: string }>} menus
|
||||
*/
|
||||
export function groupMenusByRegion(menus) {
|
||||
const list = Array.isArray(menus) ? menus : []
|
||||
if (!list.length) return []
|
||||
|
||||
const buckets = new Map()
|
||||
for (const menu of list) {
|
||||
const meta = resolveRegionMeta(menu && menu.groupTitle)
|
||||
const key = meta.key
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, { ...meta, menus: [] })
|
||||
}
|
||||
buckets.get(key).menus.push(menu)
|
||||
}
|
||||
|
||||
const ordered = []
|
||||
for (const def of REGION_DEFS) {
|
||||
if (buckets.has(def.key)) {
|
||||
ordered.push(buckets.get(def.key))
|
||||
buckets.delete(def.key)
|
||||
}
|
||||
}
|
||||
for (const region of buckets.values()) {
|
||||
ordered.push(region)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { groupMenusByRegion, resolveRegionMeta } from './menu-regions.js'
|
||||
|
||||
assert.deepEqual(resolveRegionMeta('市场部'), {
|
||||
key: 'market',
|
||||
title: '市场部',
|
||||
hint: '存储现场',
|
||||
tone: 'market'
|
||||
})
|
||||
assert.deepEqual(resolveRegionMeta('OA工作流'), {
|
||||
key: 'oa',
|
||||
title: 'OA工作流',
|
||||
hint: '审批协同',
|
||||
tone: 'oa'
|
||||
})
|
||||
assert.equal(resolveRegionMeta('').key, 'other')
|
||||
assert.equal(resolveRegionMeta(null).tone, 'other')
|
||||
|
||||
const menus = [
|
||||
{
|
||||
id: '1',
|
||||
title: '发起审批',
|
||||
groupTitle: 'OA工作流',
|
||||
path: '/pages/oa/start/index'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '预签约订单(专员)',
|
||||
groupTitle: '市场部',
|
||||
path: '/pages/market/webview/index?entry=specialist-presign'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '待办中心',
|
||||
groupTitle: 'OA工作流',
|
||||
path: '/pages/oa/pending/index'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '协议单(经理)',
|
||||
groupTitle: '市场部',
|
||||
path: '/pages/market/webview/index?entry=manager-protocol'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: '孤立入口',
|
||||
path: '/pages/demo/index'
|
||||
}
|
||||
]
|
||||
|
||||
const regions = groupMenusByRegion(menus)
|
||||
assert.equal(regions.length, 3)
|
||||
assert.deepEqual(
|
||||
regions.map((r) => r.title),
|
||||
['市场部', 'OA工作流', '其他']
|
||||
)
|
||||
assert.deepEqual(
|
||||
regions[0].menus.map((m) => m.id),
|
||||
['2', '4']
|
||||
)
|
||||
assert.deepEqual(
|
||||
regions[1].menus.map((m) => m.id),
|
||||
['1', '3']
|
||||
)
|
||||
assert.equal(regions[0].tone, 'market')
|
||||
assert.equal(regions[1].tone, 'oa')
|
||||
assert.equal(regions[2].tone, 'other')
|
||||
assert.deepEqual(groupMenusByRegion([]), [])
|
||||
|
||||
console.log('menu-regions.test.js ok')
|
||||
+44
-20
@@ -1,5 +1,5 @@
|
||||
import { get } from './request.js'
|
||||
import { getToken } from './auth.js'
|
||||
import { getToken } from './auth-session.js'
|
||||
import { resolveMenuIconColors } from './menu-icon-colors.js'
|
||||
|
||||
function authHeader() {
|
||||
@@ -7,27 +7,51 @@ function authHeader() {
|
||||
return token ? { Authorization: 'Bearer ' + token } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 市场业务叶子 path → van-icon 名(与 static/menu-icons/market-*.png 对齐)。
|
||||
* 后端未下发 icon 时由前端兜底,避免空置文字宫格。
|
||||
*/
|
||||
export function resolveMarketMenuIcon(path) {
|
||||
const p = String(path || '').toLowerCase()
|
||||
if (p.indexOf('market/webview') < 0 || p.indexOf('entry=') < 0) {
|
||||
return ''
|
||||
}
|
||||
if (p.indexOf('payment-chase') >= 0) return 'market-payment-chase'
|
||||
if (p.indexOf('presign') >= 0) return 'market-presign'
|
||||
if (p.indexOf('protocol') >= 0) return 'market-protocol'
|
||||
if (p.indexOf('payment') >= 0) return 'market-payment'
|
||||
if (p.indexOf('freight') >= 0) return 'market-freight'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 当前用户移动端菜单(扁平列表) */
|
||||
export async function fetchMyMobileMenus() {
|
||||
const data = await get('/mini/menus', authHeader())
|
||||
const list = data?.list || data || []
|
||||
return list
|
||||
.map((m) => {
|
||||
const icon = m.icon || 'approve'
|
||||
const colors = resolveMenuIconColors({
|
||||
icon,
|
||||
iconBg: m.iconBg || m.icon_bg,
|
||||
iconFg: m.iconFg || m.icon_fg
|
||||
})
|
||||
return {
|
||||
id: m.id,
|
||||
title: m.title || m.menuName || m.menu_name,
|
||||
icon,
|
||||
path: m.path,
|
||||
iconBg: colors.iconBg,
|
||||
iconFg: colors.iconFg,
|
||||
tone: m.tone || ''
|
||||
}
|
||||
})
|
||||
.filter((m) => m.path)
|
||||
return list.map(normalizeMenu).filter((m) => m.path)
|
||||
}
|
||||
|
||||
export function normalizeMenu(m = {}) {
|
||||
const hasExplicitIcon = Object.prototype.hasOwnProperty.call(m, 'icon')
|
||||
let icon = hasExplicitIcon ? String(m.icon ?? '') : 'approve'
|
||||
const path = m.path || ''
|
||||
if (!icon) {
|
||||
const marketIcon = resolveMarketMenuIcon(path)
|
||||
if (marketIcon) icon = marketIcon
|
||||
}
|
||||
const colors = resolveMenuIconColors({
|
||||
icon,
|
||||
iconBg: m.iconBg || m.icon_bg,
|
||||
iconFg: m.iconFg || m.icon_fg
|
||||
})
|
||||
return {
|
||||
id: m.id,
|
||||
title: m.title || m.menuName || m.menu_name,
|
||||
groupTitle: m.groupTitle || m.group_title || '',
|
||||
icon,
|
||||
path,
|
||||
iconBg: colors.iconBg,
|
||||
iconFg: colors.iconFg,
|
||||
tone: m.tone || ''
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,38 @@
|
||||
* H5 上 uni.navigateBack 偶发既不成功也不进 fail;离开 OA web-view 时做兜底。
|
||||
* @param {{ fallbackUrl?: string, onStuck?: () => void }} [opts]
|
||||
*/
|
||||
export function isOaHostWebviewRoute(route) {
|
||||
const value = String(route || '')
|
||||
return (
|
||||
value.indexOf('oa/start/webview') !== -1 ||
|
||||
value.indexOf('oa/webview/') !== -1 ||
|
||||
value.indexOf('market/webview/') !== -1
|
||||
)
|
||||
}
|
||||
|
||||
export function navigateBackFromOaHost(opts) {
|
||||
const onStuck = opts && opts.onStuck
|
||||
|
||||
const currentRoute = () => {
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages && pages[pages.length - 1]
|
||||
return String((cur && (cur.route || (cur.$page && cur.$page.fullPath))) || '')
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
const pageRoute = (page) => {
|
||||
return String(
|
||||
(page && (page.route || (page.$page && page.$page.fullPath))) || ''
|
||||
)
|
||||
}
|
||||
|
||||
const stillOnWebview = () => {
|
||||
const route = currentRoute()
|
||||
return (
|
||||
route.indexOf('oa/start/webview') !== -1 ||
|
||||
route.indexOf('oa/webview/') !== -1
|
||||
)
|
||||
const samePageStillOnTop = (start) => {
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
const current = pages && pages[pages.length - 1]
|
||||
return (
|
||||
!!current &&
|
||||
pages.length === start.depth &&
|
||||
current === start.page &&
|
||||
pageRoute(current) === start.route &&
|
||||
isOaHostWebviewRoute(start.route)
|
||||
)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveFallbackUrl = () => {
|
||||
@@ -39,7 +52,10 @@ export function navigateBackFromOaHost(opts) {
|
||||
return '/pages/oa/start/index'
|
||||
}
|
||||
|
||||
let fallbackStarted = false
|
||||
const fallback = () => {
|
||||
if (fallbackStarted) return
|
||||
fallbackStarted = true
|
||||
const fallbackUrl = resolveFallbackUrl()
|
||||
if (!fallbackUrl) {
|
||||
if (onStuck) onStuck()
|
||||
@@ -61,13 +77,19 @@ export function navigateBackFromOaHost(opts) {
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
if (pages && pages.length > 1) {
|
||||
const startPage = pages[pages.length - 1]
|
||||
const start = {
|
||||
page: startPage,
|
||||
route: pageRoute(startPage),
|
||||
depth: pages.length
|
||||
}
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => fallback()
|
||||
})
|
||||
// H5:navigateBack 偶发静默失败
|
||||
setTimeout(() => {
|
||||
if (stillOnWebview()) fallback()
|
||||
if (samePageStillOnTop(start)) fallback()
|
||||
}, 280)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { navigateBackFromOaHost } from './oa-navigate-back.js'
|
||||
|
||||
const waitForSilentProbe = () =>
|
||||
new Promise((resolve) => setTimeout(resolve, 320))
|
||||
|
||||
let redirects = []
|
||||
let stack = [
|
||||
{ route: 'pages/market/webview/index' },
|
||||
{ route: 'pages/oa/webview/index' }
|
||||
]
|
||||
|
||||
globalThis.getCurrentPages = () => stack
|
||||
globalThis.uni = {
|
||||
navigateBack() {
|
||||
stack.pop()
|
||||
},
|
||||
redirectTo({ url }) {
|
||||
redirects.push(url)
|
||||
},
|
||||
reLaunch() {
|
||||
throw new Error('redirectTo should not fail in this test')
|
||||
}
|
||||
}
|
||||
|
||||
navigateBackFromOaHost({ fallbackUrl: '/pages/index/index' })
|
||||
await waitForSilentProbe()
|
||||
assert.deepEqual(
|
||||
redirects,
|
||||
[],
|
||||
'成功返回到上一层 web-view 后不得误触发 fallback'
|
||||
)
|
||||
|
||||
redirects = []
|
||||
const stuckPage = { route: 'pages/market/webview/index' }
|
||||
stack = [{ route: 'pages/index/index' }, stuckPage]
|
||||
globalThis.uni.navigateBack = () => {}
|
||||
|
||||
navigateBackFromOaHost({ fallbackUrl: '/pages/index/index' })
|
||||
await waitForSilentProbe()
|
||||
assert.deepEqual(
|
||||
redirects,
|
||||
['/pages/index/index'],
|
||||
'同一发起页仍在相同栈顶时必须执行 fallback'
|
||||
)
|
||||
|
||||
delete globalThis.getCurrentPages
|
||||
delete globalThis.uni
|
||||
|
||||
console.log('oa-navigate-back: ok')
|
||||
@@ -41,6 +41,10 @@ export function buildMarketAttachmentPath(context) {
|
||||
return `/mini/market/${normalizedContext.marketRole}/${normalizedContext.module}/attachments`
|
||||
}
|
||||
|
||||
export function buildMarketAttachmentDownloadPath(context) {
|
||||
return `${buildMarketAttachmentPath(context)}/download-url`
|
||||
}
|
||||
|
||||
export function buildMarketActionPath(context, action, id, data = {}) {
|
||||
const normalizedContext = assertContext(context)
|
||||
const roleBase = `/mini/market/${normalizedContext.marketRole}`
|
||||
@@ -70,9 +74,14 @@ export function buildMarketActionPath(context, action, id, data = {}) {
|
||||
return `${roleBase}/payment-transfers/${encodedId}${suffix}`
|
||||
}
|
||||
}
|
||||
if (normalizedContext.module === 'sample' && ['storage-cert', 'insurance'].includes(action)) {
|
||||
if (normalizedContext.module === 'protocol' && ['audit', 'delete'].includes(action)) {
|
||||
encodeId()
|
||||
return `${buildMarketResourcePath(context, id)}/${action}`
|
||||
const source = String(data?.source || '').trim()
|
||||
if (!['cell_storage', 'research_donation'].includes(source)) {
|
||||
throw new Error('协议业务来源无效')
|
||||
}
|
||||
const suffix = action === 'audit' ? '/audit' : ''
|
||||
return `${buildMarketResourcePath(context, id)}${suffix}?source=${encodeURIComponent(source)}`
|
||||
}
|
||||
if (normalizedContext.module === 'freight' && ['link', 'unlink'].includes(action)) {
|
||||
encodeId()
|
||||
@@ -124,17 +133,20 @@ export async function saveMarketRecord(context, { id, data }, { signal } = {}) {
|
||||
|
||||
export function marketActionMethod(action) {
|
||||
if (['delete', 'delete-transfer'].includes(action)) return 'DELETE'
|
||||
return ['edit-transfer', 'edit-presign-transfer', 'storage-cert', 'insurance'].includes(action)
|
||||
return ['edit-transfer', 'edit-presign-transfer'].includes(action)
|
||||
? 'PUT'
|
||||
: 'POST'
|
||||
}
|
||||
|
||||
export async function performMarketAction(context, action, { id, data } = {}, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
const requestData = context.module === 'protocol' && ['audit', 'delete'].includes(action)
|
||||
? Object.fromEntries(Object.entries(data || {}).filter(([key]) => key !== 'source'))
|
||||
: data
|
||||
return request({
|
||||
url: buildMarketActionPath(context, action, id, data),
|
||||
method: marketActionMethod(action),
|
||||
data,
|
||||
data: action === 'delete' && context.module === 'protocol' ? undefined : requestData,
|
||||
signal
|
||||
})
|
||||
}
|
||||
@@ -164,6 +176,16 @@ export async function uploadMarketAttachment(context, file, { signal } = {}) {
|
||||
return request({ url: buildMarketAttachmentPath(context), method: 'POST', data, signal })
|
||||
}
|
||||
|
||||
export async function fetchMarketAttachmentDownloadUrl(context, data, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({
|
||||
url: buildMarketAttachmentDownloadPath(context),
|
||||
method: 'POST',
|
||||
data,
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchFreightSampleOptions(context, params, { signal } = {}) {
|
||||
const { request } = await import('../utils/http.js')
|
||||
return request({
|
||||
|
||||
@@ -3,16 +3,17 @@ import Vant from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
import formCreateMobile from '@form-create/vant'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createAppRouter } from './router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/oa-form.css'
|
||||
import './styles/oa-btn-loading.css'
|
||||
import './styles/market.css'
|
||||
import { registerOaComponents } from './oa/register-components'
|
||||
import { captureTokenFromUrl } from './utils/auth'
|
||||
|
||||
// 品牌绿主色 — 方案 A 薄荷微风
|
||||
document.documentElement.style.setProperty('--van-primary-color', '#22c55e')
|
||||
document.documentElement.style.setProperty('--van-success-color', '#10b981')
|
||||
document.documentElement.style.setProperty('--van-primary-color', '#0a9f6d')
|
||||
document.documentElement.style.setProperty('--van-success-color', '#0a9f6d')
|
||||
document.documentElement.style.setProperty('--van-danger-color', '#ef4444')
|
||||
document.documentElement.style.setProperty('--van-warning-color', '#d97706')
|
||||
// 表单 Cell / Field 更紧凑
|
||||
@@ -23,6 +24,7 @@ document.documentElement.style.setProperty('--van-cell-font-size', '14px')
|
||||
// 须在路由/组件发请求前捕获小程序传入的 ?token=
|
||||
captureTokenFromUrl()
|
||||
|
||||
const router = createAppRouter()
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(Vant)
|
||||
|
||||
@@ -11,15 +11,13 @@ const ACTIONS = Object.freeze({
|
||||
'submit-transfer',
|
||||
'withdraw-transfer'
|
||||
],
|
||||
protocol: ['view', 'create', 'edit'],
|
||||
protocol: ['view', 'create', 'audit', 'edit', 'delete'],
|
||||
'payment-chase': ['view-payment'],
|
||||
payment: ['view', 'register-transfer', 'edit-transfer', 'submit', 'withdraw'],
|
||||
sample: ['view', 'storage-cert', 'insurance'],
|
||||
freight: ['view', 'create', 'edit', 'link', 'unlink']
|
||||
})
|
||||
|
||||
const ACTION_ALIASES = Object.freeze({
|
||||
storageCert: 'storage-cert',
|
||||
registerTransfer: 'register-transfer',
|
||||
editTransfer: 'edit-transfer',
|
||||
'submit-finance': 'submit',
|
||||
|
||||
@@ -22,14 +22,14 @@ test('market API imports its context from a pure contract module', async () => {
|
||||
assert.equal(moduleResourceName('presign'), 'presign-orders')
|
||||
})
|
||||
|
||||
test('Task3 API contract does not expose unimplemented protocol writes', () => {
|
||||
test('protocol write paths reject missing or invalid source scope', () => {
|
||||
const protocol = { marketRole: 'specialist', module: 'protocol' }
|
||||
assert.throws(
|
||||
() => buildMarketActionPath(protocol, 'audit', 'protocol-1', { source: 'cell_storage' }),
|
||||
/不支持/
|
||||
() => buildMarketActionPath(protocol, 'audit', 'protocol-1'),
|
||||
/业务来源无效/
|
||||
)
|
||||
assert.throws(
|
||||
() => buildMarketActionPath(protocol, 'delete', 'protocol-1', { source: 'cell_storage' }),
|
||||
/不支持/
|
||||
() => buildMarketActionPath(protocol, 'delete', 'protocol-1', { source: 'other' }),
|
||||
/业务来源无效/
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildMarketActionPath,
|
||||
buildMarketAttachmentDownloadPath,
|
||||
buildMarketAttachmentPath,
|
||||
buildMarketFormOptionsPath,
|
||||
createPresignTransfer,
|
||||
@@ -23,12 +24,15 @@ const context = { marketRole: 'specialist', module: 'payment' }
|
||||
test('builds form option and attachment endpoints without client-controlled scope', () => {
|
||||
assert.equal(buildMarketFormOptionsPath(context), '/mini/market/specialist/payments/form-options')
|
||||
assert.equal(buildMarketAttachmentPath(context), '/mini/market/specialist/payment/attachments')
|
||||
assert.equal(
|
||||
buildMarketAttachmentDownloadPath(context),
|
||||
'/mini/market/specialist/payment/attachments/download-url'
|
||||
)
|
||||
})
|
||||
|
||||
test('uses PUT for update-style market actions', () => {
|
||||
assert.equal(marketActionMethod('edit-transfer'), 'PUT')
|
||||
assert.equal(marketActionMethod('storage-cert'), 'PUT')
|
||||
assert.equal(marketActionMethod('insurance'), 'PUT')
|
||||
assert.equal(marketActionMethod('edit-presign-transfer'), 'PUT')
|
||||
assert.equal(marketActionMethod('submit'), 'POST')
|
||||
})
|
||||
|
||||
@@ -48,6 +52,18 @@ test('maps payment and freight write actions to their actual backend endpoints',
|
||||
assert.equal(buildMarketResourcePath(context, 'p1'), '/mini/market/specialist/payments/p1')
|
||||
})
|
||||
|
||||
test('maps protocol audit and delete to source-scoped backend routes', () => {
|
||||
const protocol = { marketRole: 'specialist', module: 'protocol' }
|
||||
assert.equal(
|
||||
buildMarketActionPath(protocol, 'audit', 'p 1', { source: 'cell_storage' }),
|
||||
'/mini/market/specialist/protocols/p%201/audit?source=cell_storage'
|
||||
)
|
||||
assert.equal(
|
||||
buildMarketActionPath(protocol, 'delete', 'p/1', { source: 'research_donation' }),
|
||||
'/mini/market/specialist/protocols/p%2F1?source=research_donation'
|
||||
)
|
||||
})
|
||||
|
||||
test('maps every presign write action to the fixed backend route with encoded ids', () => {
|
||||
const presign = { marketRole: 'specialist', module: 'presign' }
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const IMAGE_RE = /\.(?:jpe?g|png|gif|bmp|webp|svg|jfif|avif|heic|heif)(?:\?|#|$)/i
|
||||
|
||||
function safeDecode(value) {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function nameFromUrl(url) {
|
||||
const path = String(url || '').split(/[?#]/)[0]
|
||||
return safeDecode(path.split('/').pop() || '')
|
||||
}
|
||||
|
||||
export function isMarketAttachmentImage(file) {
|
||||
const name = String(file?.name || file?.fileName || '')
|
||||
const url = String(file?.url || file?.fileUrl || '')
|
||||
const type = String(file?.fileType || file?.type || '')
|
||||
return type.startsWith('image/') || IMAGE_RE.test(name) || IMAGE_RE.test(url)
|
||||
}
|
||||
|
||||
export function marketAttachmentKind(value) {
|
||||
const name = String(value?.name || value?.fileName || value?.url || value || '')
|
||||
.split(/[?#]/)[0]
|
||||
.toLowerCase()
|
||||
if (/\.pdf$/.test(name)) return 'pdf'
|
||||
if (/\.(?:xls|xlsx|csv)$/.test(name)) return 'xls'
|
||||
if (/\.(?:doc|docx|ppt|pptx)$/.test(name)) return 'doc'
|
||||
if (/\.(?:zip|rar|7z)$/.test(name)) return 'zip'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function flattenAttachments(value) {
|
||||
if (value == null || value === '') return []
|
||||
if (Array.isArray(value)) return value.flatMap(flattenAttachments)
|
||||
if (typeof value === 'string') {
|
||||
return value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
if (typeof value === 'object') return [value]
|
||||
return []
|
||||
}
|
||||
|
||||
export function normalizeMarketAttachments(value) {
|
||||
return flattenAttachments(value)
|
||||
.map((item) => {
|
||||
const source = typeof item === 'string' ? { url: item } : item
|
||||
const url = String(source?.url || source?.fileUrl || '').trim()
|
||||
if (!url) return null
|
||||
const explicitNameValue = source?.name || source?.fileName || source?.originalName
|
||||
const inferredExplicitName = Boolean(String(explicitNameValue || '').trim())
|
||||
const explicitName = typeof source?.explicitName === 'boolean'
|
||||
? source.explicitName && inferredExplicitName
|
||||
: inferredExplicitName
|
||||
const name = String(explicitNameValue || nameFromUrl(url) || '未命名文件').trim()
|
||||
return {
|
||||
url,
|
||||
name,
|
||||
explicitName,
|
||||
isImage: isMarketAttachmentImage({ ...source, url, name }),
|
||||
kind: marketAttachmentKind(name || url)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function marketAttachmentRefs(value) {
|
||||
return normalizeMarketAttachments(value).map((file) => ({
|
||||
fileUrl: file.url,
|
||||
...(file.explicitName ? { fileName: file.name } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
export function appendMarketAttachment(value, attachment) {
|
||||
return [...marketAttachmentRefs(value), ...marketAttachmentRefs(attachment)]
|
||||
}
|
||||
|
||||
export function serializeMarketAttachments(value) {
|
||||
return normalizeMarketAttachments(value).map((file) => file.url).join(',')
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
appendMarketAttachment,
|
||||
isMarketAttachmentImage,
|
||||
marketAttachmentRefs,
|
||||
marketAttachmentKind,
|
||||
normalizeMarketAttachments
|
||||
} from './attachment-presenter.js'
|
||||
|
||||
test('normalizes comma-separated attachment urls without exposing encoded names', () => {
|
||||
assert.deepEqual(
|
||||
normalizeMarketAttachments(
|
||||
'https://cdn.example.com/a%20b.pdf,https://cdn.example.com/photo.png?token=1'
|
||||
),
|
||||
[
|
||||
{
|
||||
url: 'https://cdn.example.com/a%20b.pdf',
|
||||
name: 'a b.pdf',
|
||||
explicitName: false,
|
||||
isImage: false,
|
||||
kind: 'pdf'
|
||||
},
|
||||
{
|
||||
url: 'https://cdn.example.com/photo.png?token=1',
|
||||
name: 'photo.png',
|
||||
explicitName: false,
|
||||
isImage: true,
|
||||
kind: 'other'
|
||||
}
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('preserves object file metadata while accepting fileUrl aliases', () => {
|
||||
assert.deepEqual(
|
||||
normalizeMarketAttachments([
|
||||
{ fileUrl: '/minio/x/report.xlsx', fileName: '费用明细.xlsx' },
|
||||
{ url: '/minio/x/form.docx', name: '知情同意书.docx' },
|
||||
null,
|
||||
{ url: '' }
|
||||
]),
|
||||
[
|
||||
{
|
||||
url: '/minio/x/report.xlsx',
|
||||
name: '费用明细.xlsx',
|
||||
explicitName: true,
|
||||
isImage: false,
|
||||
kind: 'xls'
|
||||
},
|
||||
{
|
||||
url: '/minio/x/form.docx',
|
||||
name: '知情同意书.docx',
|
||||
explicitName: true,
|
||||
isImage: false,
|
||||
kind: 'doc'
|
||||
}
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('classifies workflow file icons and image extensions', () => {
|
||||
assert.equal(marketAttachmentKind('contract.pdf'), 'pdf')
|
||||
assert.equal(marketAttachmentKind('sheet.csv'), 'xls')
|
||||
assert.equal(marketAttachmentKind('slides.pptx'), 'doc')
|
||||
assert.equal(marketAttachmentKind('archive.7z'), 'zip')
|
||||
assert.equal(marketAttachmentKind('unknown.bin'), 'other')
|
||||
assert.equal(isMarketAttachmentImage({ name: 'photo.webp' }), true)
|
||||
assert.equal(isMarketAttachmentImage({ url: '/files/photo.HEIC?x=1' }), true)
|
||||
assert.equal(isMarketAttachmentImage({ name: 'contract.pdf' }), false)
|
||||
})
|
||||
|
||||
test('keeps URL-derived names implicit after attachment add and remove cycles', () => {
|
||||
const refs = appendMarketAttachment('/minio/uuid-old.pdf', {
|
||||
fileUrl: '/minio/uuid-new.pdf',
|
||||
fileName: '新凭证.pdf'
|
||||
})
|
||||
assert.deepEqual(refs, [
|
||||
{ fileUrl: '/minio/uuid-old.pdf' },
|
||||
{ fileUrl: '/minio/uuid-new.pdf', fileName: '新凭证.pdf' }
|
||||
])
|
||||
assert.deepEqual(
|
||||
marketAttachmentRefs(normalizeMarketAttachments(refs).slice(0, 1)),
|
||||
[
|
||||
{ fileUrl: '/minio/uuid-old.pdf' }
|
||||
]
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export const MARKET_ATTACHMENT_ACCEPT = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx'
|
||||
].join(',')
|
||||
|
||||
export const MARKET_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
|
||||
export const PRESIGN_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
MARKET_ATTACHMENT_ACCEPT,
|
||||
MARKET_ATTACHMENT_MAX_BYTES,
|
||||
PRESIGN_ATTACHMENT_MAX_BYTES
|
||||
} from './attachment-upload-policy.js'
|
||||
|
||||
test('matches backend market attachment size limits', () => {
|
||||
assert.equal(MARKET_ATTACHMENT_MAX_BYTES, 10 * 1024 * 1024)
|
||||
assert.equal(PRESIGN_ATTACHMENT_MAX_BYTES, 20 * 1024 * 1024)
|
||||
})
|
||||
|
||||
test('only accepts file extensions supported by the market backend', () => {
|
||||
for (const extension of ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', '.pdf', '.doc', '.docx', '.xls', '.xlsx']) {
|
||||
assert.match(MARKET_ATTACHMENT_ACCEPT, new RegExp(`(?:^|,)\\${extension}(?:,|$)`))
|
||||
}
|
||||
for (const extension of ['.ppt', '.pptx', '.zip', '.rar', '.7z']) {
|
||||
assert.doesNotMatch(MARKET_ATTACHMENT_ACCEPT, new RegExp(`(?:^|,)\\${extension}(?:,|$)`))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div class="market-attachment-cards">
|
||||
<van-uploader
|
||||
v-if="editable"
|
||||
class="market-attachment-cards__uploader"
|
||||
:disabled="uploading"
|
||||
:after-read="afterRead"
|
||||
:max-size="maxSize"
|
||||
:accept="accept"
|
||||
multiple
|
||||
@oversize="showToast(maxSizeMessage)"
|
||||
>
|
||||
<div class="market-attachment-card is-add">
|
||||
<van-loading v-if="uploading" size="22" color="#0a9f6d" />
|
||||
<span v-else class="market-attachment-card__plus">+</span>
|
||||
<span>{{ uploading ? '上传中…' : '添加' }}</span>
|
||||
</div>
|
||||
</van-uploader>
|
||||
|
||||
<article
|
||||
v-for="(file, index) in files"
|
||||
:key="`${file.url}:${index}`"
|
||||
class="market-attachment-card is-file"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="market-attachment-card__main"
|
||||
:aria-label="file.isImage ? `预览${file.name}` : `下载${file.name}`"
|
||||
@click="openAttachment(file, index)"
|
||||
>
|
||||
<img
|
||||
v-if="imageVisible(file, index)"
|
||||
:src="fullUrl(file.url)"
|
||||
:alt="file.name"
|
||||
@error="markImageFailed(file, index)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="market-attachment-file-icon"
|
||||
:class="`ft-${file.kind || 'other'}`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
<path v-if="file.kind === 'xls'" d="M8 13h8M8 17h5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="market-attachment-card__name" :title="file.name">
|
||||
{{ shortName(file.name) }}
|
||||
</span>
|
||||
<span v-if="!imageVisible(file, index)" class="market-attachment-card__download">
|
||||
点击下载
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="market-attachment-card__download-action"
|
||||
:aria-label="`下载${file.name}`"
|
||||
@click.stop="downloadAttachment(file)"
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="editable"
|
||||
type="button"
|
||||
class="market-attachment-card__delete"
|
||||
:aria-label="`删除${file.name}`"
|
||||
:disabled="uploading"
|
||||
@click="$emit('remove', index)"
|
||||
>
|
||||
<span>×</span>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { showImagePreview, showToast } from 'vant'
|
||||
import { fetchMarketAttachmentDownloadUrl } from '../../api/market.js'
|
||||
import { getMarketRouteContext } from '../routes.js'
|
||||
import { normalizeMarketAttachments } from '../attachment-presenter.js'
|
||||
import {
|
||||
MARKET_ATTACHMENT_ACCEPT,
|
||||
MARKET_ATTACHMENT_MAX_BYTES
|
||||
} from '../attachment-upload-policy.js'
|
||||
import { attachmentFullUrl } from '../../utils/attachment.js'
|
||||
import { downloadAttachmentFile } from '../../utils/download-attachment.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Array, String, Object], default: '' },
|
||||
editable: { type: Boolean, default: false },
|
||||
uploading: { type: Boolean, default: false },
|
||||
maxSize: { type: Number, default: MARKET_ATTACHMENT_MAX_BYTES },
|
||||
accept: {
|
||||
type: String,
|
||||
default: MARKET_ATTACHMENT_ACCEPT
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['upload', 'remove'])
|
||||
|
||||
const route = useRoute()
|
||||
const context = computed(() => getMarketRouteContext(route))
|
||||
const failedImages = ref(new Set())
|
||||
const files = computed(() => normalizeMarketAttachments(props.modelValue))
|
||||
const maxSizeMessage = computed(
|
||||
() => `单个附件不能超过${Math.floor(props.maxSize / 1024 / 1024)}MB`
|
||||
)
|
||||
|
||||
function fileKey(file, index) {
|
||||
return `${file.url}:${index}`
|
||||
}
|
||||
|
||||
function fullUrl(url) {
|
||||
return attachmentFullUrl(url) || url
|
||||
}
|
||||
|
||||
function imageVisible(file, index) {
|
||||
return file.isImage && !failedImages.value.has(fileKey(file, index))
|
||||
}
|
||||
|
||||
function markImageFailed(file, index) {
|
||||
failedImages.value = new Set([...failedImages.value, fileKey(file, index)])
|
||||
}
|
||||
|
||||
function shortName(value) {
|
||||
const name = String(value || '未命名文件')
|
||||
if (name.length <= 12) return name
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot > 0 && name.length - dot <= 6) {
|
||||
const extension = name.slice(dot)
|
||||
return `${name.slice(0, Math.max(4, 10 - extension.length))}…${extension}`
|
||||
}
|
||||
return `${name.slice(0, 10)}…`
|
||||
}
|
||||
|
||||
function afterRead(value) {
|
||||
for (const entry of Array.isArray(value) ? value : [value]) {
|
||||
const file = entry?.file || entry
|
||||
if (file) emit('upload', file)
|
||||
}
|
||||
}
|
||||
|
||||
async function openAttachment(file, index) {
|
||||
const url = fullUrl(file.url)
|
||||
if (!url) {
|
||||
showToast('附件地址无效')
|
||||
return
|
||||
}
|
||||
if (imageVisible(file, index)) {
|
||||
showImagePreview({ images: [url], closeable: true })
|
||||
return
|
||||
}
|
||||
await downloadAttachment(file)
|
||||
}
|
||||
|
||||
async function downloadAttachment(file) {
|
||||
try {
|
||||
showToast({ message: '开始下载…', duration: 800 })
|
||||
await downloadAttachmentFile(
|
||||
{
|
||||
fileUrl: file.url,
|
||||
fileName: file.explicitName ? file.name : undefined
|
||||
},
|
||||
{
|
||||
fetchDownloadUrl: (data) => fetchMarketAttachmentDownloadUrl(context.value, data)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
showToast(error?.message || '下载失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.market-attachment-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.market-attachment-cards__uploader :deep(.van-uploader__wrapper),
|
||||
.market-attachment-cards__uploader :deep(.van-uploader__input-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.market-attachment-card {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
overflow: visible;
|
||||
border: 1px solid #e4eee9;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px rgb(20 55 40 / 6%);
|
||||
}
|
||||
|
||||
.market-attachment-card.is-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1.5px dashed #9ad9be;
|
||||
background: linear-gradient(145deg, #f7fdf9, #e9f9f2);
|
||||
color: #087f57;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.market-attachment-card__plus {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(145deg, #4ade80, #0a9f6d);
|
||||
box-shadow: 0 4px 10px rgb(10 159 109 / 28%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.market-attachment-card__main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 6px 20px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: #fff;
|
||||
color: #4d635a;
|
||||
}
|
||||
|
||||
.market-attachment-card__main img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.market-attachment-file-icon {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 8px rgb(15 40 30 / 15%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.market-attachment-file-icon svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.market-attachment-file-icon.ft-pdf { background: linear-gradient(145deg, #f07171, #ef4444); }
|
||||
.market-attachment-file-icon.ft-xls { background: linear-gradient(145deg, #43b98b, #0a9f6d); }
|
||||
.market-attachment-file-icon.ft-doc { background: linear-gradient(145deg, #6ea8ff, #3b82f6); }
|
||||
.market-attachment-file-icon.ft-zip { background: linear-gradient(145deg, #e4bc6a, #d6a343); }
|
||||
.market-attachment-file-icon.ft-other { background: linear-gradient(145deg, #4ade80, #0a9f6d); }
|
||||
|
||||
.market-attachment-card__name {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: #4d635a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.market-attachment-card__main img + .market-attachment-card__name {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 4px 4px;
|
||||
background: linear-gradient(180deg, rgb(15 23 42 / 0%), rgb(15 23 42 / 72%));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.market-attachment-card__download {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 4px 4px 5px;
|
||||
background: linear-gradient(180deg, rgb(8 127 87 / 0%), rgb(8 127 87 / 92%));
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.market-attachment-card__download-action {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.market-attachment-card__download-action span {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #0a9f6d;
|
||||
box-shadow: 0 3px 8px rgb(10 159 109 / 28%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.market-attachment-card__download-action svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.market-attachment-card__delete {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.market-attachment-card__delete span {
|
||||
display: grid;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgb(15 23 42 / 62%);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -33,8 +33,6 @@ const ACTION_LABELS = {
|
||||
'delete-transfer': '删除意向金',
|
||||
'submit-transfer': '提交财务确认',
|
||||
'withdraw-transfer': '撤回意向金',
|
||||
'storage-cert': '办理储存证',
|
||||
insurance: '办理保险',
|
||||
link: '关联样本',
|
||||
unlink: '取消运费'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<section class="market-detail-section">
|
||||
<h2 class="market-detail-section__title">{{ title }}</h2>
|
||||
<dl class="market-detail-section__fields">
|
||||
<template v-for="field in normalizedFields" :key="field.label">
|
||||
<dt>{{ field.label }}</dt>
|
||||
<dd>
|
||||
<MarketAttachmentCards
|
||||
v-if="field.type === 'attachments'"
|
||||
:model-value="field.value"
|
||||
:editable="false"
|
||||
/>
|
||||
<van-tag v-else-if="field.tag" class="market-tag" :type="field.tagType || 'primary'" round :title="field.value">
|
||||
{{ field.value }}
|
||||
</van-tag>
|
||||
<span v-else>{{ field.value }}</span>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-if="normalizedCards.length" class="market-detail-section__nested">
|
||||
<article v-for="card in normalizedCards" :key="card.key" class="market-detail-section__nested-card">
|
||||
<strong>{{ card.title }}</strong>
|
||||
<van-tag v-if="card.statusText" class="market-tag" :type="card.statusTone" round :title="card.statusText">{{ card.statusText }}</van-tag>
|
||||
<div
|
||||
v-for="field in card.fields"
|
||||
:key="field.label"
|
||||
class="market-detail-section__nested-field"
|
||||
:class="{ 'is-attachments': field.type === 'attachments' }"
|
||||
>
|
||||
<small>{{ field.label }}</small>
|
||||
<MarketAttachmentCards
|
||||
v-if="field.type === 'attachments'"
|
||||
:model-value="field.value"
|
||||
:editable="false"
|
||||
/>
|
||||
<span v-else>{{ field.value }}</span>
|
||||
</div>
|
||||
<div v-if="card.cards?.length" class="market-detail-section__subcards">
|
||||
<article v-for="subcard in card.cards" :key="subcard.key" class="market-detail-section__subcard">
|
||||
<strong>{{ subcard.title }}</strong>
|
||||
<van-tag v-if="subcard.statusText" class="market-tag" :type="subcard.statusTone" round :title="subcard.statusText">
|
||||
{{ subcard.statusText }}
|
||||
</van-tag>
|
||||
<div
|
||||
v-for="field in subcard.fields"
|
||||
:key="field.label"
|
||||
class="market-detail-section__nested-field"
|
||||
:class="{ 'is-attachments': field.type === 'attachments' }"
|
||||
>
|
||||
<small>{{ field.label }}</small>
|
||||
<MarketAttachmentCards
|
||||
v-if="field.type === 'attachments'"
|
||||
:model-value="field.value"
|
||||
:editable="false"
|
||||
/>
|
||||
<span v-else>{{ field.value }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { resolveStatusTone } from '../status-tone.js'
|
||||
import MarketAttachmentCards from './MarketAttachmentCards.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
fields: { type: [Array, Object], default: () => [] },
|
||||
cards: { type: Array, default: () => [] },
|
||||
raw: { type: [Array, Object], default: null }
|
||||
})
|
||||
|
||||
const normalizedFields = computed(() => {
|
||||
if (Array.isArray(props.fields)) {
|
||||
return props.fields.map(normalizeDetailField)
|
||||
}
|
||||
return Object.entries(props.fields || {}).map(([label, value]) => ({
|
||||
label,
|
||||
value: String(value ?? '-'),
|
||||
tag: false
|
||||
}))
|
||||
})
|
||||
|
||||
function normalizeDetailField(field, index) {
|
||||
const type = field?.type === 'attachments' ? 'attachments' : ''
|
||||
return {
|
||||
label: String(field?.label || field?.name || `信息${index + 1}`),
|
||||
value: type ? field?.value : String(field?.value ?? '-'),
|
||||
type,
|
||||
tag: Boolean(field?.tag),
|
||||
tagType: field?.tagType
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCard(card, index, prefix = 'card') {
|
||||
return {
|
||||
key: String(card?.key || `${prefix}-${index}`),
|
||||
title: String(card?.title || `明细 ${index + 1}`),
|
||||
statusText: String(card?.statusText || card?.statusLabel || ''),
|
||||
statusTone: resolveStatusTone(
|
||||
card?.statusText || card?.statusLabel,
|
||||
card?.statusTone || card?.tagType
|
||||
),
|
||||
fields: (card?.fields || []).map(normalizeDetailField),
|
||||
cards: (card?.cards || []).map((child, childIndex) =>
|
||||
normalizeCard(child, childIndex, `${prefix}-${index}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCards = computed(() =>
|
||||
props.cards.map((card, index) => normalizeCard(card, index))
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="market-form-field">
|
||||
<label :id="labelId" :for="controlId">{{ field.label }}<b v-if="field.required">*</b></label>
|
||||
<div v-if="field.type === 'tabs'" :id="controlId" class="market-form-field__tabs" role="radiogroup" :aria-labelledby="labelId" :aria-required="field.required ? 'true' : undefined">
|
||||
<button v-for="option in options" :key="option.value" type="button" role="radio" :disabled="disabled" :aria-checked="modelValue === option.value" :class="{ active: modelValue === option.value }" @click="update(option.value)">{{ option.label || option.text }}</button>
|
||||
</div>
|
||||
<van-checkbox
|
||||
v-else-if="field.type === 'checkbox'"
|
||||
:model-value="Boolean(modelValue)"
|
||||
:disabled="disabled"
|
||||
shape="square"
|
||||
@update:model-value="update"
|
||||
>{{ field.label }}</van-checkbox>
|
||||
<div v-else-if="field.type === 'select' || field.type === 'multi-select'" class="market-form-field__control">
|
||||
<OaSelectField
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
:multiple="field.type === 'multi-select'"
|
||||
:disabled="disabled || field.readOnly"
|
||||
:placeholder="options.length ? '请选择' : '暂无可选数据'"
|
||||
@update:model-value="update"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="field.type === 'date' || field.type === 'datetime'" class="market-form-field__control">
|
||||
<OaDatePicker
|
||||
:model-value="modelValue"
|
||||
:type="field.type"
|
||||
:disabled="disabled || field.readOnly"
|
||||
:value-format="field.valueFormat || (field.type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')"
|
||||
@update:model-value="update"
|
||||
/>
|
||||
</div>
|
||||
<van-field
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:id="controlId"
|
||||
:model-value="modelValue ?? ''"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
autosize
|
||||
maxlength="512"
|
||||
show-word-limit
|
||||
:disabled="disabled || field.readOnly"
|
||||
placeholder="请输入"
|
||||
@update:model-value="update"
|
||||
/>
|
||||
<MarketAttachmentCards
|
||||
v-else-if="field.type === 'upload'"
|
||||
:model-value="modelValue"
|
||||
:editable="!disabled && !field.readOnly"
|
||||
:uploading="uploading"
|
||||
@upload="$emit('upload', $event)"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
<van-field
|
||||
v-else
|
||||
:id="controlId"
|
||||
:model-value="modelValue ?? ''"
|
||||
:type="inputType"
|
||||
:disabled="disabled || field.readOnly"
|
||||
:placeholder="field.readOnly ? '' : '请输入'"
|
||||
:inputmode="field.type === 'number' ? 'decimal' : undefined"
|
||||
@update:model-value="update"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useId } from 'vue'
|
||||
import OaDatePicker from '../../oa/components/OaDatePicker.vue'
|
||||
import OaSelectField from '../../oa/components/OaSelectField.vue'
|
||||
import { marketAttachmentRefs } from '../attachment-presenter.js'
|
||||
import MarketAttachmentCards from './MarketAttachmentCards.vue'
|
||||
|
||||
const props = defineProps({
|
||||
field: { type: Object, required: true },
|
||||
modelValue: { type: [String, Number, Boolean, Array], default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
uploading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'upload'])
|
||||
const inputType = computed(() => ({ number: 'number', tel: 'tel' })[props.field.type] || 'text')
|
||||
const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
const controlId = computed(() => `market-field-${instanceId}-${props.field.key}`)
|
||||
const labelId = computed(() => `${controlId.value}-label`)
|
||||
|
||||
function update(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
function removeAttachment(index) {
|
||||
const remaining = marketAttachmentRefs(props.modelValue)
|
||||
.filter((_, itemIndex) => itemIndex !== index)
|
||||
update(Array.isArray(props.modelValue)
|
||||
? remaining
|
||||
: remaining.map((item) => item.fileUrl).join(','))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<section class="market-form-section">
|
||||
<h2>{{ section.title }}</h2>
|
||||
<template v-if="section.repeat">
|
||||
<article v-for="(row, index) in rows" :key="index" class="market-form-repeat">
|
||||
<div class="market-form-repeat__head">
|
||||
<strong>{{ section.title }} {{ index + 1 }}</strong>
|
||||
<button type="button" :disabled="disabled" @click="removeRow(index)">移除</button>
|
||||
</div>
|
||||
<MarketFormField
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:model-value="row[field.key]"
|
||||
:options="fieldOptions(field, index)"
|
||||
:uploading="uploading"
|
||||
:disabled="disabled"
|
||||
@update:model-value="setRepeatValue(index, field, $event)"
|
||||
@upload="$emit('upload', { section, field, index, file: $event })"
|
||||
/>
|
||||
</article>
|
||||
<van-button plain type="primary" size="small" icon="plus" :disabled="disabled" @click="addRow">添加{{ section.title }}</van-button>
|
||||
</template>
|
||||
<MarketFormField
|
||||
v-for="field in visibleFields"
|
||||
v-else
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:model-value="value?.[field.key]"
|
||||
:options="fieldOptions(field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(field)"
|
||||
@update:model-value="setValue(field, $event)"
|
||||
@upload="$emit('upload', { section, field, file: $event })"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import MarketFormField from './MarketFormField.vue'
|
||||
import { sampleCodeOptionsForRow } from '../reference-options.js'
|
||||
|
||||
const props = defineProps({
|
||||
section: { type: Object, required: true },
|
||||
modelValue: { type: [Object, Array], default: () => ({}) },
|
||||
options: { type: Object, default: () => ({}) },
|
||||
source: { type: String, default: '' },
|
||||
mode: { type: String, default: 'create' },
|
||||
uploading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'upload', 'field-change'])
|
||||
const value = computed(() => props.modelValue || {})
|
||||
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||
const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
(field) => (!field.source || field.source === props.source) && !(field.createOnly && props.mode !== 'create')
|
||||
))
|
||||
|
||||
function fieldOptions(field, rowIndex = -1) {
|
||||
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
|
||||
if (field.key === 'sampleCodeId' && props.section.repeat && rowIndex >= 0) {
|
||||
return sampleCodeOptionsForRow(base, rows.value, rowIndex)
|
||||
}
|
||||
return base
|
||||
}
|
||||
function fieldDisabled(field) {
|
||||
return props.disabled || (field.key === 'hospitalId' && !value.value?.regionId)
|
||||
}
|
||||
function setValue(field, next) {
|
||||
emit('update:modelValue', { ...value.value, [field.key]: next })
|
||||
emit('field-change', { section: props.section, field, value: next })
|
||||
}
|
||||
function setRepeatValue(index, field, next) {
|
||||
const result = rows.value.map((row, rowIndex) => rowIndex === index ? { ...row, [field.key]: next } : row)
|
||||
emit('update:modelValue', result)
|
||||
emit('field-change', { section: props.section, field, value: next, index })
|
||||
}
|
||||
function addRow() {
|
||||
emit('update:modelValue', [...rows.value, {}])
|
||||
}
|
||||
function removeRow(index) {
|
||||
emit('update:modelValue', rows.value.filter((_, rowIndex) => rowIndex !== index))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<article class="market-list-card">
|
||||
<header class="market-list-card__head">
|
||||
<span class="market-list-card__index">{{ index + 1 }}</span>
|
||||
<span class="market-list-card__identity">
|
||||
<strong class="market-list-card__title">{{ card.title }}</strong>
|
||||
<small class="market-list-card__subtitle">{{ card.subtitle }}</small>
|
||||
</span>
|
||||
<van-tag v-if="card.status" class="market-tag" :type="statusTone" round :title="card.status">{{ card.status }}</van-tag>
|
||||
</header>
|
||||
|
||||
<div class="market-list-card__fields">
|
||||
<div
|
||||
v-for="field in card.fields"
|
||||
:key="`${field.label}:${field.value}`"
|
||||
class="market-list-card__field"
|
||||
:class="{ 'is-wide': field.wide }"
|
||||
>
|
||||
<span>{{ field.label }}</span>
|
||||
<van-tag v-if="field.tag" class="market-tag" :type="resolveStatusTone(field.value)" size="medium" :title="field.value">{{ field.value }}</van-tag>
|
||||
<b v-else :class="{ 'is-money': field.money }">{{ field.value }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="card.progressTags?.length" class="market-list-card__tags">
|
||||
<van-tag v-for="value in card.progressTags" :key="value" class="market-tag" :type="resolveStatusTone(value)" round>{{ value }}</van-tag>
|
||||
</div>
|
||||
<div v-if="card.detailTags?.length" class="market-list-card__tags">
|
||||
<van-tag v-for="value in card.detailTags" :key="value" class="market-tag" type="primary" round>{{ value }}</van-tag>
|
||||
</div>
|
||||
<p v-if="card.privacyNote" class="market-list-card__privacy">
|
||||
<van-icon name="shield-o" />{{ card.privacyNote }}
|
||||
</p>
|
||||
|
||||
<footer class="market-list-card__actions">
|
||||
<van-button
|
||||
v-for="action in card.actions"
|
||||
:key="action"
|
||||
size="small"
|
||||
:type="action === 'audit' || action === 'view' || action === 'view-payment-detail' ? 'primary' : action === 'delete' || action === 'unlink' ? 'danger' : 'default'"
|
||||
:plain="action !== 'audit' && action !== 'view' && action !== 'view-payment-detail'"
|
||||
:icon="actionIcon(action)"
|
||||
@click="$emit('action', action, item)"
|
||||
>{{ actionLabel(action) }}</van-button>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { actionLabel, buildMarketCard } from '../list-presenter.js'
|
||||
import { resolveStatusTone } from '../status-tone.js'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
module: { type: String, required: true },
|
||||
role: { type: String, required: true }
|
||||
})
|
||||
|
||||
defineEmits(['action'])
|
||||
|
||||
const card = computed(() => buildMarketCard(props.module, props.role, props.item))
|
||||
const statusTone = computed(() => resolveStatusTone(card.value.status))
|
||||
|
||||
function actionIcon(action) {
|
||||
return {
|
||||
view: 'eye-o', edit: 'edit', audit: 'passed', delete: 'delete-o',
|
||||
'view-payment': 'balance-o', 'view-payment-detail': 'balance-list-o', 'view-protocol': 'description',
|
||||
'register-transfer': 'balance-pay', link: 'link-o', unlink: 'disconnect'
|
||||
}[action] || 'records-o'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<section class="market-page">
|
||||
<header v-if="showHeader" class="market-page__header">
|
||||
<div class="market-page__heading">
|
||||
<p v-if="eyebrow" class="market-page__eyebrow">{{ eyebrow }}</p>
|
||||
<h1 class="market-page__title">{{ title }}</h1>
|
||||
</div>
|
||||
<div class="market-page__header-extra">
|
||||
<slot name="header-extra" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
class="market-page__body"
|
||||
:class="{
|
||||
'has-bottom-bar': hasBottomBar,
|
||||
'is-headerless': !showHeader
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<slot name="bottom" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
eyebrow: { type: String, default: '' },
|
||||
showHeader: { type: Boolean, default: true },
|
||||
hasBottomBar: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
// Vant Toast forbidClick 泄漏时 body 会挂 van-toast--unclickable,整页无法点击
|
||||
document.body.classList.remove('van-toast--unclickable')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<MarketAttachmentCards
|
||||
:model-value="modelValue"
|
||||
:editable="!disabled"
|
||||
:uploading="uploading"
|
||||
:max-size="PRESIGN_ATTACHMENT_MAX_BYTES"
|
||||
@upload="$emit('upload', $event)"
|
||||
@remove="$emit('remove', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { PRESIGN_ATTACHMENT_MAX_BYTES } from '../attachment-upload-policy.js'
|
||||
import MarketAttachmentCards from './MarketAttachmentCards.vue'
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
uploading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue', 'upload', 'remove'])
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<article class="presign-order-card">
|
||||
<button
|
||||
type="button"
|
||||
class="presign-order-card__head"
|
||||
:aria-label="`查看${card.title}的预签约订单详情`"
|
||||
@click="$emit('view', item)"
|
||||
>
|
||||
<span class="presign-order-card__index">{{ card.index }}</span>
|
||||
<span class="presign-order-card__identity">
|
||||
<strong class="presign-order-card__title">{{ card.title }}</strong>
|
||||
<small>母亲姓名</small>
|
||||
</span>
|
||||
<van-tag class="market-tag" :type="statusField.tone" round>{{ statusField.value }}</van-tag>
|
||||
<van-icon name="arrow" class="presign-order-card__arrow" />
|
||||
</button>
|
||||
|
||||
<dl class="presign-order-card__fields">
|
||||
<div v-for="field in detailFields" :key="field.key" class="presign-order-card__field">
|
||||
<dt>{{ field.label }}</dt>
|
||||
<dd :class="{ 'is-money': field.money }">{{ field.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<footer v-if="card.actions.length" class="presign-order-card__actions">
|
||||
<van-button
|
||||
v-if="can('edit')"
|
||||
size="small"
|
||||
icon="edit"
|
||||
:disabled="Boolean(pendingAction)"
|
||||
:loading="pendingAction === 'edit'"
|
||||
@click.stop="handleAction('edit')"
|
||||
>编辑</van-button>
|
||||
<van-button
|
||||
v-if="can('convert')"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon="exchange"
|
||||
:disabled="Boolean(pendingAction)"
|
||||
:loading="pendingAction === 'convert'"
|
||||
@click.stop="handleAction('convert')"
|
||||
>转正式</van-button>
|
||||
<van-button
|
||||
v-if="can('delete')"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
icon="delete-o"
|
||||
:disabled="Boolean(pendingAction)"
|
||||
:loading="pendingAction === 'delete'"
|
||||
@click.stop="handleAction('delete')"
|
||||
>删除</van-button>
|
||||
</footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
buildPresignCardModel,
|
||||
presignCardActionEvent
|
||||
} from '../presign/presign-list-model.js'
|
||||
|
||||
const props = defineProps({
|
||||
item: { type: Object, required: true },
|
||||
index: { type: Number, required: true },
|
||||
role: { type: String, required: true },
|
||||
pendingAction: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['view', 'edit', 'convert', 'delete'])
|
||||
const card = computed(() => buildPresignCardModel(props.item, props.index, props.role))
|
||||
const statusField = computed(() => card.value.fields.find((field) => field.key === 'orderStatus'))
|
||||
const detailFields = computed(() => card.value.fields.filter(
|
||||
(field) => !['motherName', 'orderStatus'].includes(field.key)
|
||||
))
|
||||
|
||||
function can(action) {
|
||||
return card.value.actions.includes(action)
|
||||
}
|
||||
|
||||
function handleAction(action) {
|
||||
const event = presignCardActionEvent(card.value, props.item, action)
|
||||
if (event) emit(event.name, event.item)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="presign-select-field">
|
||||
<van-field
|
||||
:model-value="displayText"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
readonly
|
||||
:is-link="!disabled"
|
||||
:border="false"
|
||||
@click="open"
|
||||
/>
|
||||
|
||||
<van-popup v-model:show="show" round position="bottom" teleport="body">
|
||||
<template v-if="multiple">
|
||||
<div class="presign-select-field__sheet">
|
||||
<header class="presign-select-field__header">
|
||||
<button type="button" @click="show = false">取消</button>
|
||||
<strong>{{ title }}</strong>
|
||||
<button type="button" class="is-primary" @click="confirmMultiple">确定</button>
|
||||
</header>
|
||||
<van-search
|
||||
v-if="searchable"
|
||||
v-model="keyword"
|
||||
shape="round"
|
||||
placeholder="搜索选项"
|
||||
/>
|
||||
<van-loading v-if="loading" class="presign-select-field__loading" color="#0a9f6d">
|
||||
检索中…
|
||||
</van-loading>
|
||||
<div class="presign-select-field__options">
|
||||
<van-checkbox-group v-model="pendingValues">
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="String(option.value)"
|
||||
type="button"
|
||||
class="presign-select-field__option"
|
||||
@click="toggle(option.value)"
|
||||
>
|
||||
<van-checkbox :name="option.value" @click.stop />
|
||||
<span>{{ option.text }}</span>
|
||||
</button>
|
||||
</van-checkbox-group>
|
||||
<van-empty v-if="!filteredOptions.length" description="暂无选项" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-search
|
||||
v-if="searchable"
|
||||
v-model="keyword"
|
||||
shape="round"
|
||||
placeholder="搜索选项"
|
||||
/>
|
||||
<van-loading v-if="loading" class="presign-select-field__loading" color="#0a9f6d">
|
||||
检索中…
|
||||
</van-loading>
|
||||
<van-picker
|
||||
:title="title"
|
||||
:model-value="pickerValue"
|
||||
:columns="filteredOptions"
|
||||
:columns-field-names="{ text: 'text', value: 'value' }"
|
||||
@cancel="show = false"
|
||||
@confirm="confirmSingle"
|
||||
/>
|
||||
</template>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number, Array], default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false },
|
||||
searchable: { type: Boolean, default: false },
|
||||
remote: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
title: { type: String, default: '请选择' },
|
||||
placeholder: { type: String, default: '请选择' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'search'])
|
||||
const show = ref(false)
|
||||
const keyword = ref('')
|
||||
const pendingValues = ref([])
|
||||
let searchTimer = null
|
||||
|
||||
const normalizedOptions = computed(() => props.options.map((option) => ({
|
||||
...option,
|
||||
text: String(option?.label ?? option?.text ?? option?.name ?? ''),
|
||||
value: option?.value ?? option?.id ?? ''
|
||||
})))
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const search = keyword.value.trim().toLowerCase()
|
||||
if (!search || props.remote) return normalizedOptions.value
|
||||
return normalizedOptions.value.filter((option) =>
|
||||
option.text.toLowerCase().includes(search)
|
||||
)
|
||||
})
|
||||
|
||||
const selectedValues = computed(() => {
|
||||
if (props.multiple) return Array.isArray(props.modelValue) ? props.modelValue : []
|
||||
return props.modelValue === '' || props.modelValue == null ? [] : [props.modelValue]
|
||||
})
|
||||
|
||||
const displayText = computed(() => selectedValues.value
|
||||
.map((value) => normalizedOptions.value.find(
|
||||
(option) => String(option.value) === String(value)
|
||||
)?.text || String(value))
|
||||
.filter(Boolean)
|
||||
.join('、'))
|
||||
|
||||
const pickerValue = computed(() => selectedValues.value.slice(0, 1))
|
||||
|
||||
function open() {
|
||||
if (props.disabled) return
|
||||
keyword.value = ''
|
||||
pendingValues.value = [...selectedValues.value]
|
||||
show.value = true
|
||||
if (props.remote) emit('search', '')
|
||||
}
|
||||
|
||||
function emitValue(value) {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
|
||||
function confirmSingle({ selectedValues: values }) {
|
||||
emitValue(values?.[0] ?? '')
|
||||
show.value = false
|
||||
}
|
||||
|
||||
function toggle(value) {
|
||||
const exists = pendingValues.value.some((item) => String(item) === String(value))
|
||||
pendingValues.value = exists
|
||||
? pendingValues.value.filter((item) => String(item) !== String(value))
|
||||
: [...pendingValues.value, value]
|
||||
}
|
||||
|
||||
function confirmMultiple() {
|
||||
emitValue([...pendingValues.value])
|
||||
show.value = false
|
||||
}
|
||||
|
||||
watch(keyword, (value) => {
|
||||
if (!props.remote) return
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => emit('search', String(value || '').trim()), 250)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(searchTimer))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.presign-select-field__sheet {
|
||||
display: flex;
|
||||
height: min(70vh, 560px);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.presign-select-field__header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #edf3f0;
|
||||
}
|
||||
|
||||
.presign-select-field__header button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #6b7f77;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.presign-select-field__header button.is-primary {
|
||||
color: #0a9f6d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.presign-select-field__options {
|
||||
flex: 1;
|
||||
padding: 0 12px 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.presign-select-field__loading {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.presign-select-field__option {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #edf3f0;
|
||||
background: #fff;
|
||||
color: #18332a;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,6 @@ export const MARKET_MODULES = Object.freeze([
|
||||
'protocol',
|
||||
'payment-chase',
|
||||
'payment',
|
||||
'sample',
|
||||
'freight'
|
||||
])
|
||||
|
||||
@@ -14,7 +13,6 @@ const RESOURCE_BY_MODULE = Object.freeze({
|
||||
protocol: 'protocols',
|
||||
'payment-chase': 'payment-chase',
|
||||
payment: 'payments',
|
||||
sample: 'samples',
|
||||
freight: 'freights'
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./components/MarketFormField.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('form fields expose stable label associations and required state', () => {
|
||||
assert.match(source, /:id="controlId"/)
|
||||
assert.match(source, /:for="controlId"/)
|
||||
assert.match(source, /:aria-labelledby="labelId"/)
|
||||
assert.match(source, /:aria-required="field\.required \? 'true' : undefined"/)
|
||||
})
|
||||
|
||||
test('form controls support a shared disabled state', () => {
|
||||
assert.match(source, /disabled:/)
|
||||
assert.match(source, /:disabled="disabled/)
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { serializeMarketAttachments } from './attachment-presenter.js'
|
||||
|
||||
const FORBIDDEN_KEYS = new Set([
|
||||
'token',
|
||||
'scope',
|
||||
'fieldScope',
|
||||
'tenantNo',
|
||||
'tenant_no'
|
||||
])
|
||||
|
||||
export function sanitizeMarketPayload(value) {
|
||||
if (Array.isArray(value)) return value.map(sanitizeMarketPayload)
|
||||
if (!value || typeof value !== 'object') return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) => !FORBIDDEN_KEYS.has(key))
|
||||
.map(([key, item]) => [key, sanitizeMarketPayload(item)])
|
||||
)
|
||||
}
|
||||
|
||||
function pickFields(fields, values, businessSource) {
|
||||
const result = {}
|
||||
for (const field of fields || []) {
|
||||
if (field.source && field.source !== businessSource) continue
|
||||
if (Object.prototype.hasOwnProperty.call(values || {}, field.key)) {
|
||||
result[field.key] = field.type === 'upload'
|
||||
? serializeMarketAttachments(values[field.key])
|
||||
: values[field.key]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function pickConfiguredPayload(sections, formData) {
|
||||
const result = {}
|
||||
const businessSource = formData?.source
|
||||
for (const section of sections || []) {
|
||||
if (section.readOnly || section.uiOnly) continue
|
||||
if (section.repeat) {
|
||||
const rows = Array.isArray(formData?.[section.key]) ? formData[section.key] : []
|
||||
result[section.key] = rows.map((row) => pickFields(section.fields, row, businessSource))
|
||||
} else if (section.nested) {
|
||||
result[section.key] = pickFields(section.fields, formData?.[section.key] || {}, businessSource)
|
||||
} else {
|
||||
Object.assign(result, pickFields(section.fields, formData || {}, businessSource))
|
||||
}
|
||||
}
|
||||
return sanitizeMarketPayload(result)
|
||||
}
|
||||
|
||||
export function buildMarketActionPayload(module, action, payload) {
|
||||
const clean = sanitizeMarketPayload(payload)
|
||||
const pick = (keys) => Object.fromEntries(
|
||||
keys.filter((key) => Object.prototype.hasOwnProperty.call(clean || {}, key))
|
||||
.map((key) => [key, clean[key]])
|
||||
)
|
||||
if (module === 'freight' && ['link', 'unlink'].includes(action)) {
|
||||
return { items: clean.items || [] }
|
||||
}
|
||||
if (module === 'payment' && ['register-transfer', 'edit-transfer'].includes(action)) {
|
||||
const transferKeys = ['transferAmount', 'transferTime', 'transferType', 'transferAttachments', 'remark']
|
||||
const result = pick(action === 'register-transfer' ? ['scheduleId', ...transferKeys] : transferKeys)
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'transferAttachments')) {
|
||||
result.transferAttachments = serializeMarketAttachments(result.transferAttachments)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
export function validateRequiredFields(sections, formData) {
|
||||
const empty = (value) => value == null || value === '' || (Array.isArray(value) && value.length === 0)
|
||||
for (const section of sections || []) {
|
||||
const values = section.repeat
|
||||
? (Array.isArray(formData?.[section.key]) ? formData[section.key] : [])
|
||||
: [section.nested ? formData?.[section.key] || {} : formData || {}]
|
||||
for (const row of values) {
|
||||
for (const field of section.fields || []) {
|
||||
const value = row?.[field.key]
|
||||
const missing = field.type === 'checkbox' ? value !== true : empty(value)
|
||||
if (field.required && (!field.source || field.source === formData?.source) && missing) {
|
||||
return `请填写${field.label}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildMarketActionPayload,
|
||||
pickConfiguredPayload,
|
||||
sanitizeMarketPayload,
|
||||
validateRequiredFields
|
||||
} from './form-payload.js'
|
||||
|
||||
test('removes authorization and routing fields recursively from form payloads', () => {
|
||||
assert.deepEqual(
|
||||
sanitizeMarketPayload({
|
||||
token: 'secret',
|
||||
scope: 'manager',
|
||||
tenantNo: 'other',
|
||||
motherName: '张三',
|
||||
collection: { fieldScope: 'specialist', regionId: 'r1' },
|
||||
samples: [{ sampleCode: 'S1', salesUserId: 'u2' }]
|
||||
}),
|
||||
{
|
||||
motherName: '张三',
|
||||
collection: { regionId: 'r1' },
|
||||
samples: [{ sampleCode: 'S1', salesUserId: 'u2' }]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('validates required fields in root and nested form sections', () => {
|
||||
const sections = [
|
||||
{ key: 'base', fields: [{ key: 'name', label: '姓名', required: true }] },
|
||||
{ key: 'transfer', nested: true, fields: [{ key: 'amount', label: '金额', required: true }] }
|
||||
]
|
||||
assert.equal(validateRequiredFields(sections, { name: '', transfer: { amount: '1' } }), '请填写姓名')
|
||||
assert.equal(validateRequiredFields(sections, { name: 'A', transfer: { amount: '' } }), '请填写金额')
|
||||
assert.equal(validateRequiredFields(sections, { name: 'A', transfer: { amount: '1' } }), '')
|
||||
})
|
||||
|
||||
test('requires a required checkbox to be strictly confirmed', () => {
|
||||
const sections = [
|
||||
{
|
||||
key: 'confirm',
|
||||
nested: true,
|
||||
fields: [{ key: 'confirmed', label: '我已核对以上信息', type: 'checkbox', required: true }]
|
||||
}
|
||||
]
|
||||
assert.equal(
|
||||
validateRequiredFields(sections, { confirm: { confirmed: false } }),
|
||||
'请填写我已核对以上信息'
|
||||
)
|
||||
assert.equal(validateRequiredFields(sections, { confirm: { confirmed: true } }), '')
|
||||
})
|
||||
|
||||
test('action payloads exclude fields rejected by strict backend DTOs', () => {
|
||||
assert.deepEqual(
|
||||
buildMarketActionPayload('payment', 'edit-transfer', {
|
||||
id: 't1', scheduleId: 's1', transferAmount: '10', transferTime: '2026-10-11',
|
||||
transferType: 'bank', transferAttachments: 'a.pdf', remark: '备注', payStatus: 'dfj',
|
||||
statusLabel: '待提交', statusTone: 'warning'
|
||||
}),
|
||||
{
|
||||
transferAmount: '10', transferTime: '2026-10-11', transferType: 'bank',
|
||||
transferAttachments: 'a.pdf', remark: '备注'
|
||||
}
|
||||
)
|
||||
assert.deepEqual(
|
||||
buildMarketActionPayload('payment', 'register-transfer', {
|
||||
scheduleId: 's1', transferAmount: '10', id: 'forged', statusLabel: '待提交'
|
||||
}),
|
||||
{ scheduleId: 's1', transferAmount: '10' }
|
||||
)
|
||||
assert.deepEqual(
|
||||
buildMarketActionPayload('freight', 'link', {
|
||||
transportMode: 'air', items: [{ sampleSource: 'cell_storage', sampleId: 'x' }]
|
||||
}),
|
||||
{ items: [{ sampleSource: 'cell_storage', sampleId: 'x' }] }
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps only configured writable fields including nested and repeated sections', () => {
|
||||
const sections = [
|
||||
{ key: 'base', fields: [{ key: 'name' }] },
|
||||
{ key: 'customer', nested: true, fields: [{ key: 'phone' }] },
|
||||
{ key: 'samples', repeat: true, fields: [{ key: 'sampleCode' }] }
|
||||
]
|
||||
assert.deepEqual(
|
||||
pickConfiguredPayload(sections, {
|
||||
id: 'readonly', name: 'A', status: 'done',
|
||||
customer: { phone: '1', tenantNo: 'x' },
|
||||
samples: [{ id: 's1', sampleCode: 'S1' }]
|
||||
}),
|
||||
{ name: 'A', customer: { phone: '1' }, samples: [{ sampleCode: 'S1' }] }
|
||||
)
|
||||
})
|
||||
|
||||
test('serializes attachment display metadata back to backend URL strings', () => {
|
||||
const sections = [
|
||||
{
|
||||
key: 'transfer',
|
||||
nested: true,
|
||||
fields: [{ key: 'transferAttachments', type: 'upload' }]
|
||||
}
|
||||
]
|
||||
assert.deepEqual(
|
||||
pickConfiguredPayload(sections, {
|
||||
transfer: {
|
||||
transferAttachments: [
|
||||
{ fileUrl: '/minio/a.pdf', fileName: '协议.pdf' },
|
||||
{ url: '/minio/b.jpg', name: '照片.jpg' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
{ transfer: { transferAttachments: '/minio/a.pdf,/minio/b.jpg' } }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value || {}))
|
||||
}
|
||||
|
||||
export function createMarketFormState(value = {}) {
|
||||
return { value: clone(value), dirty: false }
|
||||
}
|
||||
|
||||
export function updateFormValue(state, key, value) {
|
||||
return { value: { ...state.value, [key]: value }, dirty: true }
|
||||
}
|
||||
|
||||
export function markFormClean(state) {
|
||||
return { ...state, dirty: false }
|
||||
}
|
||||
|
||||
export function canLeaveMarketForm({ allowLeave, pending, dirty }) {
|
||||
if (allowLeave) return true
|
||||
if (pending) return false
|
||||
return !dirty
|
||||
}
|
||||
|
||||
export function createSubmitGuard() {
|
||||
let isPending = false
|
||||
return {
|
||||
get pending() {
|
||||
return isPending
|
||||
},
|
||||
async run(task) {
|
||||
if (isPending) throw new Error('正在提交,请勿重复操作')
|
||||
isPending = true
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
isPending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createAsyncGenerationGuard() {
|
||||
let generation = 0
|
||||
let active = true
|
||||
return {
|
||||
begin() {
|
||||
generation += 1
|
||||
return generation
|
||||
},
|
||||
isCurrent(value) {
|
||||
return active && value === generation
|
||||
},
|
||||
dispose() {
|
||||
active = false
|
||||
generation += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
canLeaveMarketForm,
|
||||
createSubmitGuard,
|
||||
createAsyncGenerationGuard,
|
||||
createMarketFormState,
|
||||
markFormClean,
|
||||
updateFormValue
|
||||
} from './form-state.js'
|
||||
|
||||
test('blocks cancel and dirty navigation while a form request is pending', () => {
|
||||
assert.equal(canLeaveMarketForm({ allowLeave: false, pending: true, dirty: false }), false)
|
||||
assert.equal(canLeaveMarketForm({ allowLeave: false, pending: true, dirty: true }), false)
|
||||
assert.equal(canLeaveMarketForm({ allowLeave: true, pending: true, dirty: true }), true)
|
||||
assert.equal(canLeaveMarketForm({ allowLeave: false, pending: false, dirty: false }), true)
|
||||
})
|
||||
|
||||
test('tracks dirty state until a successful save marks the form clean', () => {
|
||||
const initial = createMarketFormState({ motherName: '张三' })
|
||||
const changed = updateFormValue(initial, 'motherName', '李四')
|
||||
assert.equal(changed.dirty, true)
|
||||
assert.equal(markFormClean(changed).dirty, false)
|
||||
})
|
||||
|
||||
test('invalidates async completions after unmount or a newer request', () => {
|
||||
const guard = createAsyncGenerationGuard()
|
||||
const first = guard.begin()
|
||||
assert.equal(guard.isCurrent(first), true)
|
||||
const second = guard.begin()
|
||||
assert.equal(guard.isCurrent(first), false)
|
||||
assert.equal(guard.isCurrent(second), true)
|
||||
guard.dispose()
|
||||
assert.equal(guard.isCurrent(second), false)
|
||||
})
|
||||
|
||||
test('submit guard rejects concurrent submissions and releases after completion', async () => {
|
||||
const guard = createSubmitGuard()
|
||||
let release
|
||||
const first = guard.run(() => new Promise((resolve) => { release = resolve }))
|
||||
assert.equal(guard.pending, true)
|
||||
await assert.rejects(() => guard.run(async () => 'duplicate'), /正在提交/)
|
||||
release('ok')
|
||||
assert.equal(await first, 'ok')
|
||||
assert.equal(guard.pending, false)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('../views/market/MarketModuleForm.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('freight option searches abort stale requests and ignore post-unmount completions', () => {
|
||||
assert.match(source, /sampleOptionsController\?\.abort\(\)/)
|
||||
assert.match(source, /fetchFreightSampleOptions\([\s\S]*\{ signal: sampleOptionsController\.signal \}/)
|
||||
assert.match(source, /sampleOptionsGuard\.isCurrent\(requestToken\)/)
|
||||
assert.match(source, /sampleOptionsGuard\.dispose\(\)/)
|
||||
})
|
||||
|
||||
test('form option requests include the selected action when the contract needs it', () => {
|
||||
assert.match(source, /formOptionsParams\?\.\(id\.value, action\.value\)/)
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export function marketErrorMessage(error, fallback = '操作失败,请稍后重试') {
|
||||
const status = Number(error?.status || error?.statusCode || error?.response?.status || 0)
|
||||
if (status === 401) return '登录已失效,请返回小程序重新登录'
|
||||
if (status === 403 || status === 404) return '数据不存在或无权访问'
|
||||
return String(error?.message || fallback)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { marketErrorMessage } from './market-error.js'
|
||||
|
||||
test('maps authentication and object access errors without leaking existence', () => {
|
||||
assert.equal(marketErrorMessage({ status: 401 }), '登录已失效,请返回小程序重新登录')
|
||||
assert.equal(marketErrorMessage({ status: 403, message: '存在但无权限' }), '数据不存在或无权访问')
|
||||
assert.equal(marketErrorMessage({ status: 404, message: '真实对象名称' }), '数据不存在或无权访问')
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { getMarketModule } from './module-registry.js'
|
||||
import { marketListComponentKey, MARKET_MODULES, MARKET_ROLES } from './routes.js'
|
||||
|
||||
function source(relativePath) {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
test('market list pages omit their in-page title bars', () => {
|
||||
const genericList = source('../views/market/MarketModuleList.vue')
|
||||
const genericDetail = source('../views/market/MarketModuleDetail.vue')
|
||||
const genericForm = source('../views/market/MarketModuleForm.vue')
|
||||
const presignList = source('../views/market/presign/PresignOrderList.vue')
|
||||
const presignForm = source('../views/market/presign/PresignOrderForm.vue')
|
||||
|
||||
assert.match(genericList, /:show-header="false"/)
|
||||
assert.match(genericDetail, /:show-header="false"/)
|
||||
assert.match(genericForm, /:show-header="false"/)
|
||||
assert.doesNotMatch(genericList, /:backable=/)
|
||||
assert.doesNotMatch(presignList, /<van-nav-bar/)
|
||||
assert.doesNotMatch(presignForm, /<van-nav-bar/)
|
||||
})
|
||||
|
||||
test('market detail pages omit the floating round back button', () => {
|
||||
const genericDetail = source('../views/market/MarketModuleDetail.vue')
|
||||
|
||||
assert.doesNotMatch(genericDetail, /:backable=/)
|
||||
assert.doesNotMatch(genericDetail, /@back="backToList"/)
|
||||
})
|
||||
|
||||
test('market create and edit pages omit every floating round back button', () => {
|
||||
const shell = source('./components/MarketPageShell.vue')
|
||||
const styles = source('../styles/market.css')
|
||||
const genericForm = source('../views/market/MarketModuleForm.vue')
|
||||
const presignForm = source('../views/market/presign/PresignOrderForm.vue')
|
||||
|
||||
assert.doesNotMatch(shell, /market-page__floating-back/)
|
||||
assert.doesNotMatch(shell, /has-floating-back/)
|
||||
assert.doesNotMatch(shell, /aria-label="返回"/)
|
||||
assert.doesNotMatch(shell, /\bbackable\b/)
|
||||
assert.doesNotMatch(genericForm, /:backable=/)
|
||||
assert.doesNotMatch(genericForm, /@back="back"/)
|
||||
assert.doesNotMatch(presignForm, /presign-form-page__back/)
|
||||
assert.doesNotMatch(presignForm, /aria-label="返回"/)
|
||||
assert.doesNotMatch(styles, /market-page__floating-back/)
|
||||
assert.doesNotMatch(styles, /presign-form-page__back/)
|
||||
assert.match(styles, /\.presign-form-page__content\s*\{[^}]*?padding:\s*calc\(12px \+ env\(safe-area-inset-top\)\)/)
|
||||
})
|
||||
|
||||
test('market list visual contract keeps search white, tags filled and actions right aligned', () => {
|
||||
const styles = source('../styles/market.css')
|
||||
const card = source('./components/MarketListCard.vue')
|
||||
|
||||
assert.match(styles, /\.market-list__filters\s*\{[\s\S]*?background:\s*#fff;/)
|
||||
assert.match(styles, /\.presign-order-page__search\s*\{[\s\S]*?top:\s*0;/)
|
||||
assert.match(styles, /\.market-list-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/)
|
||||
assert.match(styles, /\.presign-order-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/)
|
||||
assert.match(styles, /\.market-tag\s*\{[\s\S]*?align-self:\s*flex-start;/)
|
||||
assert.match(styles, /\.market-tag\s*\{[\s\S]*?max-width:\s*112px;/)
|
||||
assert.match(card, /class="market-tag"/)
|
||||
assert.doesNotMatch(card, /<van-tag[^>]*\bplain\b/)
|
||||
for (const component of [
|
||||
'./components/MarketDetailSection.vue',
|
||||
'./components/PresignTransferCard.vue',
|
||||
'../views/market/MarketModuleDetail.vue'
|
||||
]) {
|
||||
const content = source(component)
|
||||
assert.doesNotMatch(content, /<van-tag(?![^>]*class="market-tag")/)
|
||||
}
|
||||
})
|
||||
|
||||
test('protocol lists wire dual source tabs into listParams and create navigation', () => {
|
||||
const genericList = source('../views/market/MarketModuleList.vue')
|
||||
const styles = source('../styles/market.css')
|
||||
|
||||
assert.match(genericList, /market-source-tabs/)
|
||||
assert.match(genericList, /market-source-tabs__btn/)
|
||||
assert.match(genericList, /type="button"/)
|
||||
assert.match(genericList, /@click="switchListStatus\(tab\.value\)"/)
|
||||
assert.doesNotMatch(genericList, /\[market-protocol-tabs\]/)
|
||||
assert.doesNotMatch(genericList, /bindNativeTabListeners/)
|
||||
assert.doesNotMatch(genericList, /installGlobalPointerProbe/)
|
||||
assert.doesNotMatch(genericList, /<van-tabs/)
|
||||
assert.match(genericList, /listParams\?\.\(\{\s*status:\s*listStatus\.value\s*\}\)/)
|
||||
assert.match(genericList, /source:\s*listStatus\.value/)
|
||||
assert.match(genericList, /usesSourceTabs/)
|
||||
assert.match(genericList, /module === 'protocol'/)
|
||||
assert.match(genericList, /clearStalePointerLocks/)
|
||||
assert.match(styles, /\.market-source-tabs__btn\s*\{/)
|
||||
assert.match(styles, /cursor:\s*pointer/)
|
||||
assert.doesNotMatch(styles, /\.market-list__filters\s*\{[^}]*position:\s*sticky/)
|
||||
})
|
||||
|
||||
test('all ten market list entries keep cards inside the horizontal content area', () => {
|
||||
const styles = source('../styles/market.css')
|
||||
const routes = source('./routes.js')
|
||||
|
||||
assert.match(styles, /\.market-list-card\s*\{[^}]*?box-sizing:\s*border-box;[^}]*?width:\s*100%;/)
|
||||
assert.match(styles, /\.presign-order-card\s*\{[^}]*?overflow:\s*hidden;/)
|
||||
assert.match(routes, /export const MARKET_ROLES = \['specialist', 'manager'\]/)
|
||||
assert.match(routes, /export const MARKET_MODULES = \[[\s\S]*?'presign'[\s\S]*?'protocol'[\s\S]*?'payment-chase'[\s\S]*?'payment'[\s\S]*?'freight'/)
|
||||
assert.doesNotMatch(routes, /'sample'/)
|
||||
})
|
||||
|
||||
test('all ten market list routes use templates with brand green create buttons and white icons', () => {
|
||||
const styles = source('../styles/market.css')
|
||||
const genericList = source('../views/market/MarketModuleList.vue')
|
||||
const presignList = source('../views/market/presign/PresignOrderList.vue')
|
||||
const templateKeys = MARKET_ROLES.flatMap(() => MARKET_MODULES.map(marketListComponentKey))
|
||||
|
||||
assert.equal(templateKeys.length, 10)
|
||||
assert.equal(templateKeys.filter((key) => key === 'presign-list').length, 2)
|
||||
assert.equal(templateKeys.filter((key) => key === 'generic-list').length, 8)
|
||||
assert.match(genericList, /class="market-list__fab"/)
|
||||
assert.match(presignList, /class="market-list__fab"/)
|
||||
assert.doesNotMatch(presignList, /van-floating-bubble/)
|
||||
assert.match(styles, /\.market-list__fab\s*\{[^}]*?background:\s*var\(--market-primary,\s*#0a9f6d\)\s*!important;[^}]*?color:\s*#fff;/)
|
||||
})
|
||||
|
||||
test('market create and edit bottom actions are centered', () => {
|
||||
const styles = source('../styles/market.css')
|
||||
const genericForm = source('../views/market/MarketModuleForm.vue')
|
||||
|
||||
assert.match(genericForm, /market-bottom-bar market-form-bottom/)
|
||||
assert.match(styles, /\.market-form-bottom\s*\{[\s\S]*?justify-content:\s*center;/)
|
||||
assert.match(styles, /\.presign-form-bottom\s*\{[\s\S]*?justify-content:\s*center;/)
|
||||
})
|
||||
|
||||
test('market attachments use the workflow thumbnail and file-icon contract', () => {
|
||||
const attachmentCards = source('./components/MarketAttachmentCards.vue')
|
||||
|
||||
assert.match(attachmentCards, /normalizeMarketAttachments/)
|
||||
assert.match(attachmentCards, /downloadAttachmentFile/)
|
||||
assert.match(attachmentCards, /fetchMarketAttachmentDownloadUrl/)
|
||||
assert.match(attachmentCards, /showImagePreview/)
|
||||
assert.match(attachmentCards, /market-attachment-card__download-action/)
|
||||
assert.match(attachmentCards, /@click\.stop="downloadAttachment\(file\)"/)
|
||||
assert.match(attachmentCards, /fetchDownloadUrl:/)
|
||||
assert.match(attachmentCards, /market-attachment-card__delete/)
|
||||
assert.match(attachmentCards, /@error=/)
|
||||
assert.match(attachmentCards, /84px/)
|
||||
assert.match(attachmentCards, /overflow:\s*visible/)
|
||||
assert.match(attachmentCards, /market-attachment-card__main[\s\S]*?overflow:\s*hidden/)
|
||||
assert.match(attachmentCards, /\.market-attachment-card__download-action\s*\{[\s\S]*?width:\s*44px;[\s\S]*?height:\s*44px;/)
|
||||
assert.match(attachmentCards, /file\.explicitName\s*\?\s*file\.name\s*:\s*undefined/)
|
||||
assert.doesNotMatch(attachmentCards, /#10b981/i)
|
||||
for (const kind of ['pdf', 'xls', 'doc', 'zip', 'other']) {
|
||||
assert.match(attachmentCards, new RegExp(`ft-${kind}`))
|
||||
}
|
||||
})
|
||||
|
||||
test('market forms share attachment cards and preserve their existing value shapes', () => {
|
||||
const genericField = source('./components/MarketFormField.vue')
|
||||
const presignUploader = source('./components/PresignAttachmentUploader.vue')
|
||||
const genericForm = source('../views/market/MarketModuleForm.vue')
|
||||
|
||||
for (const content of [genericField, presignUploader]) {
|
||||
assert.match(content, /import MarketAttachmentCards/)
|
||||
assert.match(content, /<MarketAttachmentCards/)
|
||||
}
|
||||
assert.match(genericField, /marketAttachmentRefs/)
|
||||
assert.match(genericField, /marketAttachmentRefs[\s\S]*?\.map\(\(item\) => item\.fileUrl\)[\s\S]*?\.join\(','\)/)
|
||||
assert.match(presignUploader, /@upload="\$emit\('upload', \$event\)"/)
|
||||
assert.match(presignUploader, /@remove="\$emit\('remove', \$event\)"/)
|
||||
assert.match(presignUploader, /PRESIGN_ATTACHMENT_MAX_BYTES/)
|
||||
assert.match(genericForm, /appendMarketAttachment/)
|
||||
assert.match(genericForm, /fileName:\s*uploaded\?\.fileName\s*\|\|\s*file\.name/)
|
||||
})
|
||||
|
||||
test('payment detail presents every web field and nested transfer field with Chinese labels', () => {
|
||||
const sections = getMarketModule('payment').detailSections({
|
||||
protocolNo: 'P-1',
|
||||
motherName: '张三',
|
||||
motherPhone: '13800138000',
|
||||
sampleCode: 'S-1',
|
||||
payMode: 'fq',
|
||||
contractAmount: '10000.00',
|
||||
paidAmount: '3000.00',
|
||||
remainAmount: '7000.00',
|
||||
clearedScheduleCount: 1,
|
||||
activeScheduleCount: 3,
|
||||
protocolStatus: 'sxz',
|
||||
paymentProgress: '部分缴费',
|
||||
schedules: [{
|
||||
id: 'schedule-1',
|
||||
scheduleNo: 1,
|
||||
dueAmount: '5000.00',
|
||||
dueDate: '2026-12-01',
|
||||
paidAmount: '3000.00',
|
||||
arrearsAmount: '2000.00',
|
||||
scheduleStatus: 'open',
|
||||
transfers: [{
|
||||
id: 'transfer-1',
|
||||
payStatus: 'jfqrz',
|
||||
transferType: 'wx',
|
||||
transferAmount: '3000.00',
|
||||
transferTime: '2026-10-11 10:00:00',
|
||||
transferAttachments: 'proof.pdf',
|
||||
remark: '首笔'
|
||||
}]
|
||||
}]
|
||||
})
|
||||
|
||||
assert.deepEqual(sections.map((section) => section.title), ['缴费概览', '应缴批次'])
|
||||
assert.deepEqual(
|
||||
sections[0].fields.map((field) => field.label),
|
||||
['协议号', '客户', '联系电话', '样本编码', '缴费方式', '合同额', '已缴 / 待缴', '整体进度', '协议状态', '缴费进度']
|
||||
)
|
||||
assert.deepEqual(
|
||||
sections[1].cards[0].fields.map((field) => field.label),
|
||||
['应缴金额', '应缴日', '已缴金额', '欠缴金额']
|
||||
)
|
||||
assert.deepEqual(
|
||||
sections[1].cards[0].cards[0].fields.map((field) => field.label),
|
||||
['转账方式', '转账金额', '转账时间', '转账凭证', '备注']
|
||||
)
|
||||
})
|
||||
|
||||
test('payment cards reproduce the Web Excel columns and operation labels', () => {
|
||||
const presenter = source('./list-presenter.js')
|
||||
for (const label of [
|
||||
'姓名', '手机号', '样本编码', '签署日期', '存储年限', '存储项目', '合同总额',
|
||||
'缴费方式', '优惠金额', '应收总额', '应缴费金额(元)', '应缴费时间',
|
||||
'已缴费金额', '已缴费时间', '欠缴费金额', '累计实缴', '待缴金额', '备注'
|
||||
]) assert.match(presenter, new RegExp(label))
|
||||
assert.match(presenter, /'view-payment-detail': '缴费明细'/)
|
||||
})
|
||||
|
||||
test('nested market detail cards use explicit labels instead of API property names', () => {
|
||||
const detailSection = source('./components/MarketDetailSection.vue')
|
||||
|
||||
assert.match(detailSection, /cards:/)
|
||||
assert.doesNotMatch(detailSection, /function flattenFields/)
|
||||
})
|
||||
|
||||
test('market detail sections render typed attachments as readonly cards', () => {
|
||||
const detailSection = source('./components/MarketDetailSection.vue')
|
||||
|
||||
assert.match(detailSection, /import MarketAttachmentCards/)
|
||||
assert.match(detailSection, /field\.type === 'attachments'/)
|
||||
assert.match(detailSection, /<MarketAttachmentCards/)
|
||||
assert.match(detailSection, /:editable="false"/)
|
||||
})
|
||||
|
||||
test('protocol cards expose the same business fields as the web list', async () => {
|
||||
const { buildMarketCard } = await import('./list-presenter.js')
|
||||
const card = buildMarketCard('protocol', 'specialist', {
|
||||
source: 'cell_storage', protocolNo: 'P-1', protocolStatus: 'sxz', registerDate: '20261011',
|
||||
motherName: '张三', motherPhone: '13800138000', motherIdCard: '3401', fatherName: '李四',
|
||||
newbornSex: '1', deliveryMode: 'sc', newbornWeightGram: 3200, regionName: '合肥',
|
||||
hospitalName: '省妇幼', collectorUserName: '采集员', salesUserName: '市场员',
|
||||
sampleCollectTime: '2026-10-11 10:00:00', createTime: '2026-10-11 11:00:00',
|
||||
allowedActions: ['edit', 'delete']
|
||||
})
|
||||
|
||||
assert.deepEqual(card.fields.map((field) => field.label), [
|
||||
'状态', '登记日期', '母亲姓名', '母亲电话', '母亲身份证号', '父亲姓名',
|
||||
'新生儿性别', '分娩方式', '新生儿体重(克)', '采集地区', '采集医院',
|
||||
'采集专员', '市场专员', '样本采集时间', '创建时间'
|
||||
])
|
||||
assert.deepEqual(card.actions, ['view', 'edit', 'delete'])
|
||||
})
|
||||
|
||||
test('protocol, freight and presign details define explicit Chinese nested cards', () => {
|
||||
const protocol = getMarketModule('protocol').detailSections({
|
||||
source: 'cell_storage',
|
||||
basic: { protocolNo: 'P-1', protocolStatus: 'sxz' },
|
||||
customer: { motherName: '张三' },
|
||||
samples: [{ id: 's1', sampleCode: 'S-1', sampleTypeName: '脐带血' }],
|
||||
attachments: { protocolAttachments: 'a.pdf' }
|
||||
})
|
||||
const freight = getMarketModule('freight').detailSections({
|
||||
freightNo: 'F-1', transportModeLabel: '冷链', freightAmount: '20',
|
||||
items: [{ id: 'i1', sampleSource: 'cell_storage', protocolNo: 'P-1', sampleCode: 'S-1' }]
|
||||
})
|
||||
const presign = getMarketModule('presign').detailSections({
|
||||
transfers: [{ id: 't1', transferType: 'wxzz', transferAmount: '20', payStatus: 'yjf' }]
|
||||
})
|
||||
|
||||
for (const sections of [protocol, freight, presign]) {
|
||||
for (const section of sections) {
|
||||
assert.ok(Array.isArray(section.fields))
|
||||
assert.ok(Array.isArray(section.cards || []))
|
||||
for (const card of section.cards || []) {
|
||||
assert.ok(card.title)
|
||||
assert.ok(card.fields.every((field) => /[\u3400-\u9fff]/.test(field.label)))
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(protocol.find((section) => section.key === 'samples').cards.length)
|
||||
assert.ok(freight.find((section) => section.key === 'items').cards.length)
|
||||
assert.ok(presign.find((section) => section.key === 'transfers').cards.length)
|
||||
})
|
||||
|
||||
test('protocol create and edit forms expose every writable web business field', () => {
|
||||
const module = getMarketModule('protocol')
|
||||
const bySection = Object.fromEntries(
|
||||
module.formSections.map((section) => [section.key, section.fields.map((field) => field.key)])
|
||||
)
|
||||
|
||||
assert.deepEqual(bySection.protocol, [
|
||||
'source', 'protocolNo', 'donationNo', 'registerDate', 'donationDate',
|
||||
'collectorUserId', 'protocolTotalAmount', 'remark'
|
||||
])
|
||||
assert.deepEqual(bySection.collection, [
|
||||
'salesUserId', 'regionId', 'hospitalId', 'regionManagerUserId', 'medicalStaff',
|
||||
'sampleCollectTime', 'maternalBloodCollectTime', 'remark'
|
||||
])
|
||||
assert.deepEqual(bySection.customer, [
|
||||
'motherName', 'motherPhone', 'motherIdCard', 'motherEmail', 'motherBirthYm',
|
||||
'motherEthnic', 'motherAddress', 'fatherName', 'fatherPhone', 'fatherIdCard',
|
||||
'newbornName', 'newbornSex', 'expectedDeliveryDate', 'newbornDeliveryTime',
|
||||
'newbornIdCard', 'deliveryMode', 'birthOrder', 'newbornWeightGram',
|
||||
'apgar1min', 'apgar5min', 'remark'
|
||||
])
|
||||
assert.deepEqual(bySection.samples, [
|
||||
'sampleTypeId', 'sampleCodeId', 'sampleCode', 'collectAmount', 'unitId',
|
||||
'price', 'originalPrice', 'storageYears', 'etaTime', 'isSpecial', 'specialRemark', 'remark'
|
||||
])
|
||||
assert.deepEqual(bySection.attachments, [
|
||||
'protocolAttachments', 'consentFormUrls', 'healthSurveyFormUrls',
|
||||
'attachmentUrls', 'infectiousDiseaseReportUrls'
|
||||
])
|
||||
const customer = module.formSections.find((section) => section.key === 'customer')
|
||||
const samples = module.formSections.find((section) => section.key === 'samples')
|
||||
assert.deepEqual(
|
||||
customer.fields.find((field) => field.key === 'motherEthnic'),
|
||||
{ key: 'motherEthnic', label: '母亲民族', type: 'select', optionKey: 'ethnicities' }
|
||||
)
|
||||
assert.deepEqual(
|
||||
samples.fields.find((field) => field.key === 'sampleCodeId'),
|
||||
{ key: 'sampleCodeId', label: '样本编码记录', type: 'select', optionKey: 'sampleCodes' }
|
||||
)
|
||||
const form = source('../views/market/MarketModuleForm.vue')
|
||||
assert.match(form, /field\.key === 'sampleCodeId'/)
|
||||
assert.match(form, /sampleCode:\s*option\?\.sampleCode/)
|
||||
})
|
||||
|
||||
test('presign delete action calls the delete API exactly once', () => {
|
||||
const list = source('../views/market/presign/PresignOrderList.vue')
|
||||
const deleteBranch = list.match(/if \(code === 'delete'\) \{([\s\S]*?)\} else \{/)
|
||||
|
||||
assert.ok(deleteBranch)
|
||||
assert.equal((deleteBranch[1].match(/deletePresignOrder\(/g) || []).length, 1)
|
||||
})
|
||||
@@ -17,8 +17,7 @@ test('each module exposes only its static market action whitelist', () => {
|
||||
'withdraw'
|
||||
])
|
||||
assert.equal(moduleActions('payment').includes('finance-confirm'), false)
|
||||
assert.equal(moduleActions('protocol').includes('audit'), false)
|
||||
assert.equal(moduleActions('sample').includes('qc-conclusion'), false)
|
||||
assert.deepEqual(moduleActions('protocol'), ['view', 'create', 'audit', 'edit', 'delete'])
|
||||
assert.equal(moduleActions('freight', 'specialist').includes('edit'), true)
|
||||
assert.equal(moduleActions('freight', 'manager').includes('edit'), true)
|
||||
assert.deepEqual(moduleActions('presign'), [
|
||||
@@ -107,15 +106,11 @@ test('backend actions are intersected with the module whitelist', () => {
|
||||
['submit', { code: 'withdraw', label: '撤回登记' }]
|
||||
)
|
||||
assert.deepEqual(
|
||||
intersectAllowedActions('sample', 'manager', [
|
||||
{ key: 'storageCert', label: '办理储存证' },
|
||||
{ key: 'insurance', label: '办理保险' },
|
||||
intersectAllowedActions('protocol', 'manager', [
|
||||
{ key: 'audit', label: '审核' },
|
||||
'qc-conclusion'
|
||||
]),
|
||||
[
|
||||
{ code: 'storage-cert', label: '办理储存证' },
|
||||
{ code: 'insurance', label: '办理保险' }
|
||||
]
|
||||
[{ code: 'audit', label: '审核' }]
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ test('module adapters map list filters to the actual backend query names', () =>
|
||||
assert.deepEqual(getMarketModule('protocol').listParams({ status: 'cell_storage' }), {
|
||||
source: 'cell_storage'
|
||||
})
|
||||
assert.deepEqual(getMarketModule('sample').listParams({ status: 'research_donation' }), {
|
||||
source: 'research_donation'
|
||||
})
|
||||
assert.deepEqual(getMarketModule('freight').listParams({ status: 'air' }), {
|
||||
transportMode: 'air',
|
||||
includeItems: true
|
||||
@@ -43,22 +40,6 @@ test('protocol list tabs match Web dual-source tabs without an all-sources optio
|
||||
})
|
||||
})
|
||||
|
||||
test('sample list tabs match Web dual-source tabs without an all-sources option', () => {
|
||||
assert.deepEqual(getMarketModule('sample').statusOptions, [
|
||||
{ text: '细胞存储', value: 'cell_storage' },
|
||||
{ text: '科研捐赠', value: 'research_donation' }
|
||||
])
|
||||
assert.deepEqual(getMarketModule('sample').listParams({}), {
|
||||
source: 'cell_storage'
|
||||
})
|
||||
assert.deepEqual(getMarketModule('sample').listParams({ status: '' }), {
|
||||
source: 'cell_storage'
|
||||
})
|
||||
assert.deepEqual(getMarketModule('sample').listParams({ status: 'research_donation' }), {
|
||||
source: 'research_donation'
|
||||
})
|
||||
})
|
||||
|
||||
test('presign filters use the backend order status codes', () => {
|
||||
assert.deepEqual(getMarketModule('presign').statusOptions, [
|
||||
{ text: '全部状态', value: '' },
|
||||
@@ -77,7 +58,7 @@ test('protocol summary status resolves real codes without exposing unknown codes
|
||||
assert.equal(protocol.statusLabel({ source: 'cell_storage', basic: { protocolStatus: '' } }), '—')
|
||||
})
|
||||
|
||||
test('protocol and sample adapters preserve nested detail sections', () => {
|
||||
test('protocol adapters preserve nested detail sections', () => {
|
||||
const protocol = getMarketModule('protocol').detailSections({
|
||||
source: 'cell_storage',
|
||||
protocol: { protocolNo: 'P-1' },
|
||||
@@ -90,11 +71,6 @@ test('protocol and sample adapters preserve nested detail sections', () => {
|
||||
assert.deepEqual(protocol.map((section) => section.key), [
|
||||
'protocol', 'collection', 'customer', 'samples', 'payment', 'attachments'
|
||||
])
|
||||
|
||||
const sample = getMarketModule('sample').detailSections({
|
||||
sections: [{ key: 'market-handles', title: '市场办理', storageCert: { statusLabel: '已完成' } }]
|
||||
})
|
||||
assert.equal(sample[0].storageCert.statusLabel, '已完成')
|
||||
})
|
||||
|
||||
test('attachment-bearing detail adapters expose typed values instead of path text', () => {
|
||||
@@ -122,15 +98,6 @@ test('attachment-bearing detail adapters expose typed values instead of path tex
|
||||
value: 'payment-proof.png'
|
||||
})
|
||||
|
||||
const sample = getMarketModule('sample').detailSections({
|
||||
sections: [{
|
||||
key: 'market-handles',
|
||||
storageCert: { attachments: 'certificate.pdf' },
|
||||
insurance: { attachments: 'insurance.jpg' }
|
||||
}]
|
||||
})
|
||||
assert.equal(sample[0].cards[0].fields.find((field) => field.label === '附件').type, 'attachments')
|
||||
|
||||
const presign = getMarketModule('presign').detailSections({
|
||||
transfers: [{ transferAttachments: 'deposit-proof.png' }]
|
||||
})
|
||||
@@ -171,7 +138,7 @@ test('presign detail keeps the Web order fields and transfer cards', () => {
|
||||
assert.equal(sections[1].raw.transfers[0].statusLabel, '付款完成')
|
||||
})
|
||||
|
||||
test('payment and sample adapters initialize only editable fields from real detail shapes', () => {
|
||||
test('payment adapters initialize only editable fields from real detail shapes', () => {
|
||||
const payment = getMarketModule('payment')
|
||||
const paymentDetail = {
|
||||
id: 'payment-1',
|
||||
@@ -183,22 +150,6 @@ test('payment and sample adapters initialize only editable fields from real deta
|
||||
assert.deepEqual(payment.initialForm(paymentDetail, { transferId: 'transfer-1' }), {
|
||||
transfer: { transferAmount: '20', transferType: 'bank' }
|
||||
})
|
||||
|
||||
const sample = getMarketModule('sample')
|
||||
const sampleDetail = { sections: [{ key: 'market-handles', storageCert: {
|
||||
handleTime: '2026-10-11', handlerUserId: 'u1', certCode: 'CERT-1', statusLabel: '已完成'
|
||||
} }] }
|
||||
assert.deepEqual(sample.initialForm(sampleDetail, 'storage-cert'), {
|
||||
handle: { handleTime: '2026-10-11', handlerUserId: 'u1' }
|
||||
})
|
||||
})
|
||||
|
||||
test('sample form options are scoped by the exact source-qualified sample id and action', () => {
|
||||
const sample = getMarketModule('sample')
|
||||
assert.deepEqual(sample.formOptionsParams('cell_storage:C-001', 'insurance'), {
|
||||
sampleId: 'cell_storage:C-001',
|
||||
action: 'insurance'
|
||||
})
|
||||
})
|
||||
|
||||
test('protocol personnel fields use isolated hydrated option collections', () => {
|
||||
@@ -286,12 +237,6 @@ test('detail adapters hide unresolved references and dictionary codes but preser
|
||||
}
|
||||
assert.equal(protocol.find((section) => section.key === 'samples').cards[0].statusText, '已采集')
|
||||
|
||||
const sample = getMarketModule('sample').detailSections({
|
||||
sections: [{ key: 'market-handles', storageCert: { handlerUserId: 'handler-raw', handlerUserName: 'handler-raw', statusLabel: 'raw-handle-status' } }]
|
||||
})
|
||||
assert.equal(sample[0].cards[0].fields.find((field) => field.label === '办理人员').value, '未知人员')
|
||||
assert.equal(sample[0].cards[0].statusText, '未知')
|
||||
|
||||
const freight = getMarketModule('freight').detailSections({
|
||||
freightNo: 'F-RAW-1', transportMode: 'raw-transport', createBy: 'creator-raw', creatorName: 'creator-raw',
|
||||
items: [{ sampleSource: 'raw-source', sampleCode: 'S-RAW-2' }]
|
||||
|
||||
@@ -3,9 +3,8 @@ import paymentChase from './modules/payment-chase.js'
|
||||
import payment from './modules/payment.js'
|
||||
import presign from './modules/presign.js'
|
||||
import protocol from './modules/protocol.js'
|
||||
import sample from './modules/sample.js'
|
||||
|
||||
const MODULES = Object.freeze({ presign, protocol, 'payment-chase': paymentChase, payment, sample, freight })
|
||||
const MODULES = Object.freeze({ presign, protocol, 'payment-chase': paymentChase, payment, freight })
|
||||
|
||||
export function getMarketModule(module) {
|
||||
const value = MODULES[module]
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
const SOURCE_LABELS = Object.freeze({ cell_storage: '细胞存储', research_donation: '科研捐赠' })
|
||||
|
||||
function dictionaryLabel(map, code, providedLabel) {
|
||||
const label = String(providedLabel || '').trim()
|
||||
const key = String(code || '').trim()
|
||||
if (label && label !== key) return label
|
||||
return map[key] || (key ? '未知' : '—')
|
||||
}
|
||||
|
||||
function person(name, id) {
|
||||
const displayName = String(name || '').trim()
|
||||
const referenceId = String(id || '').trim()
|
||||
if (displayName && displayName !== referenceId) return displayName
|
||||
return referenceId ? '未知人员' : '—'
|
||||
}
|
||||
|
||||
function money(value) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) return '—'
|
||||
return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
export default {
|
||||
key: 'freight',
|
||||
resource: 'freights',
|
||||
@@ -5,8 +27,45 @@ export default {
|
||||
specialist: ['view', 'create', 'edit', 'link', 'unlink'],
|
||||
manager: ['view', 'create', 'edit', 'link', 'unlink']
|
||||
},
|
||||
listParams: ({ status } = {}) => ({ transportMode: status || '' }),
|
||||
listParams: ({ status } = {}) => ({ transportMode: status || '', includeItems: true }),
|
||||
statusOptions: [{ text: '全部运输方式', value: '' }],
|
||||
detailSections(detail) {
|
||||
return [
|
||||
{
|
||||
key: 'base',
|
||||
title: '运费信息',
|
||||
fields: [
|
||||
{ label: '批次号', value: detail?.freightNo || '—' },
|
||||
{ label: '运输方式', value: dictionaryLabel({}, detail?.transportMode, detail?.transportModeLabel), tag: true, tagType: 'primary' },
|
||||
{ label: '运费金额', value: money(detail?.freightAmount) },
|
||||
{ label: '关联数', value: String(detail?.visibleItemCount ?? detail?.items?.length ?? 0) },
|
||||
{ label: '创建人', value: person(detail?.creatorName || detail?.createByName, detail?.createBy) },
|
||||
{ label: '创建时间', value: detail?.createTime || '—' },
|
||||
{ label: '更新时间', value: detail?.updateTime || '—' },
|
||||
{ label: '备注', value: detail?.remark || '—' }
|
||||
],
|
||||
cards: []
|
||||
},
|
||||
{
|
||||
key: 'items',
|
||||
title: '关联样本',
|
||||
fields: [],
|
||||
cards: (detail?.items || []).map((item, index) => ({
|
||||
key: String(item?.id || `item-${index}`),
|
||||
title: item?.sampleCode || `样本 ${index + 1}`,
|
||||
statusText: dictionaryLabel(SOURCE_LABELS, item?.sampleSource),
|
||||
fields: [
|
||||
{ label: '来源', value: dictionaryLabel(SOURCE_LABELS, item?.sampleSource) },
|
||||
{ label: '协议单号', value: item?.protocolNo || '—' },
|
||||
{ label: '样本编码', value: item?.sampleCode || '—' },
|
||||
{ label: '样本类型', value: item?.sampleTypeName || '—' },
|
||||
{ label: '母亲姓名', value: item?.motherName || '—' },
|
||||
{ label: '子女姓名', value: item?.newbornName || '—' }
|
||||
]
|
||||
}))
|
||||
}
|
||||
]
|
||||
},
|
||||
usesPagedSampleOptions(action) {
|
||||
return ['create', 'link'].includes(action)
|
||||
},
|
||||
|
||||
@@ -1,34 +1,170 @@
|
||||
import { section } from './shared.js'
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
dsx: '待生效', dqr: '待确认', sxz: '生效中', ygq: '已过期',
|
||||
yzf: '已作废', sdfq: '属地废弃', zky: '转科研', zckc: '转细胞存储'
|
||||
})
|
||||
const PAY_MODE_LABELS = Object.freeze({ dj: '趸交', fq: '分期', fgd: '分改趸', djfq: '趸交+分期' })
|
||||
const SEX_LABELS = Object.freeze({ '1': '男', '0': '女' })
|
||||
const DELIVERY_LABELS = Object.freeze({ sc: '顺产', pgc: '剖宫产' })
|
||||
const SAMPLE_STATUS_LABELS = Object.freeze({
|
||||
ycj: '已采集', jcz: '检测中', yrk: '已入库', yzf: '已作废'
|
||||
})
|
||||
|
||||
const labels = {
|
||||
protocolNo: '协议编号', donationNo: '捐赠编号', registerDate: '登记日期',
|
||||
donationDate: '捐赠日期', motherName: '母亲姓名', motherPhone: '联系电话',
|
||||
regionName: '采集地区', hospitalName: '采集医院', sampleCode: '样本编码',
|
||||
payMode: '缴费模式', protocolAttachments: '协议附件'
|
||||
function value(source, key) {
|
||||
const result = source?.[key]
|
||||
return result == null || result === '' ? '—' : String(result)
|
||||
}
|
||||
|
||||
function label(map, source) {
|
||||
const key = String(source || '').trim()
|
||||
return map[key] || (key ? (/[㐀-鿿]/u.test(key) ? key : '未知') : '—')
|
||||
}
|
||||
|
||||
function person(name, id) {
|
||||
const displayName = String(name || '').trim()
|
||||
const referenceId = String(id || '').trim()
|
||||
if (displayName && displayName !== referenceId) return displayName
|
||||
return referenceId ? '未知人员' : '—'
|
||||
}
|
||||
|
||||
function referenceName(name, id) {
|
||||
const displayName = String(name || '').trim()
|
||||
const referenceId = String(id || '').trim()
|
||||
if (displayName && displayName !== referenceId) return displayName
|
||||
return referenceId ? '未知' : '—'
|
||||
}
|
||||
|
||||
function fields(source, definitions) {
|
||||
return definitions.map(([key, text, options = {}]) => ({
|
||||
label: text,
|
||||
value: options.map ? options.map(source?.[key]) : value(source, key),
|
||||
...options,
|
||||
map: undefined
|
||||
}))
|
||||
}
|
||||
|
||||
export default {
|
||||
key: 'protocol',
|
||||
resource: 'protocols',
|
||||
actions: { specialist: ['view', 'create', 'edit'], manager: ['view', 'create', 'edit'] },
|
||||
listParams: ({ status } = {}) => ({ source: status || '' }),
|
||||
listParams: ({ status } = {}) => ({
|
||||
source: ['cell_storage', 'research_donation'].includes(String(status || '').trim())
|
||||
? String(status).trim()
|
||||
: 'cell_storage'
|
||||
}),
|
||||
statusOptions: [
|
||||
{ text: '全部来源', value: '' },
|
||||
{ text: '细胞存储', value: 'cell_storage' },
|
||||
{ text: '科研捐赠', value: 'research_donation' }
|
||||
],
|
||||
statusLabel(detail) {
|
||||
const basic = detail?.protocol || detail?.basic || detail || {}
|
||||
const status = detail?.source === 'research_donation'
|
||||
? basic?.donationStatus
|
||||
: basic?.protocolStatus
|
||||
return label(STATUS_LABELS, status)
|
||||
},
|
||||
detailSections(detail) {
|
||||
if (Array.isArray(detail?.sections)) return detail.sections
|
||||
if (Array.isArray(detail?.sections)) {
|
||||
return detail.sections.map((item) => ({ ...item, fields: item?.fields || [], cards: item?.cards || [] }))
|
||||
}
|
||||
const source = detail?.source
|
||||
const basic = detail?.protocol || detail?.basic || detail || {}
|
||||
const statusKey = source === 'research_donation' ? 'donationStatus' : 'protocolStatus'
|
||||
const dateKey = source === 'research_donation' ? 'donationDate' : 'registerDate'
|
||||
const numberKey = source === 'research_donation' ? 'donationNo' : 'protocolNo'
|
||||
const values = [
|
||||
section('protocol', '协议信息', detail?.protocol || detail?.basic || detail, labels),
|
||||
section('collection', '采集信息', detail?.collection, labels),
|
||||
section('customer', '客户信息', detail?.customer, labels),
|
||||
section('samples', '样本信息', { samples: detail?.samples }, labels)
|
||||
{
|
||||
key: 'protocol',
|
||||
title: source === 'research_donation' ? '捐赠信息' : '协议信息',
|
||||
fields: [
|
||||
{ label: source === 'research_donation' ? '捐赠编号' : '协议编号', value: value(basic, numberKey) },
|
||||
{ label: source === 'research_donation' ? '捐赠状态' : '协议状态', value: label(STATUS_LABELS, basic?.[statusKey]), tag: true },
|
||||
{ label: source === 'research_donation' ? '捐赠日期' : '登记日期', value: value(basic, dateKey) },
|
||||
{ label: '采集专员', value: person(basic?.collectorUserName, basic?.collectorUserId) },
|
||||
...(source === 'cell_storage' ? [{ label: '协议总金额', value: value(basic, 'protocolTotalAmount') }] : []),
|
||||
{ label: '备注', value: value(basic, 'remark') },
|
||||
{ label: '创建时间', value: value(basic, 'createTime') },
|
||||
{ label: '更新时间', value: value(basic, 'updateTime') }
|
||||
],
|
||||
cards: []
|
||||
},
|
||||
{
|
||||
key: 'collection',
|
||||
title: '采集信息',
|
||||
fields: fields(detail?.collection, [
|
||||
['salesUserName', '市场专员', { map: () => person(detail?.collection?.salesUserName, detail?.collection?.salesUserId) }],
|
||||
['regionName', '采集地区', { map: () => referenceName(detail?.collection?.regionName, detail?.collection?.regionId) }],
|
||||
['hospitalName', '采集医院', { map: () => referenceName(detail?.collection?.hospitalName, detail?.collection?.hospitalId) }],
|
||||
['regionManagerUserName', '地区经理', { map: () => person(detail?.collection?.regionManagerUserName, detail?.collection?.regionManagerUserId) }],
|
||||
['medicalStaff', '采集医护'],
|
||||
['sampleCollectTime', '样本采集时间'],
|
||||
['maternalBloodCollectTime', '母血采集时间'],
|
||||
['remark', '备注']
|
||||
]),
|
||||
cards: []
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
title: '客户信息',
|
||||
fields: fields(detail?.customer, [
|
||||
['motherName', '母亲姓名'], ['motherPhone', '母亲联系电话'], ['motherIdCard', '母亲身份证号'],
|
||||
['motherEmail', '母亲邮箱'], ['motherBirthYm', '母亲出生年月'], ['motherEthnic', '母亲民族'],
|
||||
['motherAddress', '母亲家庭住址'], ['fatherName', '父亲姓名'], ['fatherPhone', '父亲电话'],
|
||||
['fatherIdCard', '父亲身份证号'], ['newbornName', '新生儿姓名'],
|
||||
['newbornSex', '新生儿性别', { map: (item) => label(SEX_LABELS, item) }],
|
||||
['expectedDeliveryDate', '预产期'], ['newbornDeliveryTime', '新生儿分娩时间'],
|
||||
['newbornIdCard', '新生儿身份证'],
|
||||
['deliveryMode', '分娩方式', { map: (item) => label(DELIVERY_LABELS, item) }],
|
||||
['birthOrder', '胎次'], ['newbornWeightGram', '新生儿体重(克)'],
|
||||
['apgar1min', '新生儿评分(1分钟)'], ['apgar5min', '新生儿评分(5分钟)'], ['remark', '备注']
|
||||
]),
|
||||
cards: []
|
||||
},
|
||||
{
|
||||
key: 'samples',
|
||||
title: '样本信息',
|
||||
fields: [],
|
||||
cards: (detail?.samples || []).map((item, index) => ({
|
||||
key: String(item?.id || `sample-${index}`),
|
||||
title: item?.sampleCode || `样本 ${index + 1}`,
|
||||
statusText: label(SAMPLE_STATUS_LABELS, item?.sampleStatusLabel || item?.sampleStatus),
|
||||
fields: fields(item, [
|
||||
['sampleTypeName', '样本类型', { map: () => referenceName(item?.sampleTypeName, item?.sampleTypeId) }],
|
||||
['sampleCode', '样本编码'], ['collectAmount', '采集量'],
|
||||
['unitName', '计量单位', { map: () => referenceName(item?.unitName, item?.unitId) }],
|
||||
['price', '单价'], ['originalPrice', '原价'], ['storageYears', '存储年限'],
|
||||
['etaTime', '预计到达时间', { map: () => item?.etaTime || item?.transport?.etaTime || '' }],
|
||||
['isSpecial', '是否特殊样本', { map: (itemValue) => Number(itemValue) === 1 ? '是' : '否' }],
|
||||
['specialRemark', '特殊说明'], ['remark', '备注']
|
||||
])
|
||||
}))
|
||||
}
|
||||
]
|
||||
if (detail?.source === 'cell_storage' || detail?.payment) {
|
||||
values.push(section('payment', '缴费信息', detail?.payment, labels))
|
||||
values.push({
|
||||
key: 'payment',
|
||||
title: '缴费信息',
|
||||
fields: fields(detail?.payment, [
|
||||
['payMode', '缴费模式', { map: (item) => label(PAY_MODE_LABELS, item), tag: true }],
|
||||
['contractAmount', '合同金额'], ['installmentCount', '分期期数'],
|
||||
['installmentIntervalMonths', '每期间隔(月)'], ['lumpSumDeadline', '趸交截止时间'],
|
||||
['installmentStartDate', '分期开始时间'], ['installmentAdvanceDays', '提前催缴预警(天)'],
|
||||
['remark', '缴费备注']
|
||||
]),
|
||||
cards: []
|
||||
})
|
||||
}
|
||||
values.push(section('attachments', '附件信息', detail?.attachments, labels))
|
||||
values.push({
|
||||
key: 'attachments',
|
||||
title: '附件信息',
|
||||
fields: [
|
||||
{ label: '协议附件', type: 'attachments', value: detail?.attachments?.protocolAttachments || '' },
|
||||
{ label: '知情同意书', type: 'attachments', value: detail?.attachments?.consentFormUrls || '' },
|
||||
{ label: '孕妇健康调查表', type: 'attachments', value: detail?.attachments?.healthSurveyFormUrls || '' },
|
||||
{ label: '传染病检测报告', type: 'attachments', value: detail?.attachments?.infectiousDiseaseReportUrls || '' },
|
||||
{ label: '其他附件', type: 'attachments', value: detail?.attachments?.attachmentUrls || '' }
|
||||
],
|
||||
cards: []
|
||||
})
|
||||
return values
|
||||
},
|
||||
formSections: [
|
||||
@@ -38,26 +174,64 @@ export default {
|
||||
] },
|
||||
{ key: 'protocolNo', label: '协议编号', source: 'cell_storage' },
|
||||
{ key: 'donationNo', label: '捐赠编号', source: 'research_donation' },
|
||||
{ key: 'registerDate', label: '登记日期', type: 'date', source: 'cell_storage' },
|
||||
{ key: 'donationDate', label: '捐赠日期', type: 'date', source: 'research_donation' },
|
||||
{ key: 'registerDate', label: '登记日期', type: 'date', valueFormat: 'YYYYMMDD', source: 'cell_storage' },
|
||||
{ key: 'donationDate', label: '捐赠日期', type: 'date', valueFormat: 'YYYYMMDD', source: 'research_donation' },
|
||||
{ key: 'collectorUserId', label: '采集专员', type: 'select', optionKey: 'collectorStaff' },
|
||||
{ key: 'protocolTotalAmount', label: '协议总金额', type: 'number', source: 'cell_storage' },
|
||||
{ key: 'remark', label: '备注', type: 'textarea' }
|
||||
] },
|
||||
{ key: 'collection', title: '采集信息', nested: true, fields: [
|
||||
{ key: 'salesUserId', label: '市场专员', type: 'select', optionKey: 'salesStaff' },
|
||||
{ key: 'regionId', label: '采集地区', type: 'select', optionKey: 'regions' },
|
||||
{ key: 'hospitalId', label: '采集医院', type: 'select', optionKey: 'hospitals' },
|
||||
{ key: 'sampleCollectTime', label: '采集时间', type: 'datetime' }
|
||||
{ key: 'regionManagerUserId', label: '地区经理', type: 'select', optionKey: 'managerStaff' },
|
||||
{ key: 'medicalStaff', label: '采集医护' },
|
||||
{ key: 'sampleCollectTime', label: '样本采集时间', type: 'datetime' },
|
||||
{ key: 'maternalBloodCollectTime', label: '母血采集时间', type: 'datetime' },
|
||||
{ key: 'remark', label: '备注', type: 'textarea' }
|
||||
] },
|
||||
{ key: 'customer', title: '客户信息', nested: true, fields: [
|
||||
{ key: 'motherName', label: '母亲姓名', required: true },
|
||||
{ key: 'motherPhone', label: '联系电话', type: 'tel' },
|
||||
{ key: 'motherIdCard', label: '证件号码' },
|
||||
{ key: 'expectedDeliveryDate', label: '预产期', type: 'date' }
|
||||
{ key: 'motherIdCard', label: '母亲身份证号' },
|
||||
{ key: 'motherEmail', label: '母亲邮箱' },
|
||||
{ key: 'motherBirthYm', label: '母亲出生年月' },
|
||||
{ key: 'motherEthnic', label: '母亲民族', type: 'select', optionKey: 'ethnicities' },
|
||||
{ key: 'motherAddress', label: '母亲家庭住址', type: 'textarea' },
|
||||
{ key: 'fatherName', label: '父亲姓名' },
|
||||
{ key: 'fatherPhone', label: '父亲电话', type: 'tel' },
|
||||
{ key: 'fatherIdCard', label: '父亲身份证号' },
|
||||
{ key: 'newbornName', label: '新生儿姓名' },
|
||||
{ key: 'newbornSex', label: '新生儿性别', type: 'tabs', options: [
|
||||
{ label: '男', value: '1' }, { label: '女', value: '0' }
|
||||
] },
|
||||
{ key: 'expectedDeliveryDate', label: '预产期', type: 'date', valueFormat: 'YYYYMMDD' },
|
||||
{ key: 'newbornDeliveryTime', label: '新生儿分娩时间', type: 'datetime' },
|
||||
{ key: 'newbornIdCard', label: '新生儿身份证' },
|
||||
{ key: 'deliveryMode', label: '分娩方式', type: 'select', options: [
|
||||
{ label: '顺产', value: 'sc' }, { label: '剖宫产', value: 'pgc' }
|
||||
] },
|
||||
{ key: 'birthOrder', label: '胎次', type: 'number' },
|
||||
{ key: 'newbornWeightGram', label: '新生儿体重(克)', type: 'number' },
|
||||
{ key: 'apgar1min', label: '新生儿评分(1分钟)', type: 'number' },
|
||||
{ key: 'apgar5min', label: '新生儿评分(5分钟)', type: 'number' },
|
||||
{ key: 'remark', label: '备注', type: 'textarea' }
|
||||
] },
|
||||
{ key: 'samples', title: '样本信息', repeat: true, fields: [
|
||||
{ key: 'sampleTypeId', label: '样本类型', type: 'select', optionKey: 'sampleTypes' },
|
||||
{ key: 'sampleCodeId', label: '样本编码记录', type: 'select', optionKey: 'sampleCodes' },
|
||||
{ key: 'sampleCode', label: '样本编码' },
|
||||
{ key: 'storageYears', label: '存储年限', type: 'number', source: 'cell_storage' }
|
||||
{ key: 'collectAmount', label: '采集量', type: 'number' },
|
||||
{ key: 'unitId', label: '计量单位', type: 'select', optionKey: 'units' },
|
||||
{ key: 'price', label: '单价', type: 'number', source: 'cell_storage' },
|
||||
{ key: 'originalPrice', label: '原价', type: 'number', source: 'cell_storage' },
|
||||
{ key: 'storageYears', label: '存储年限', type: 'number', source: 'cell_storage' },
|
||||
{ key: 'etaTime', label: '预计到达时间', type: 'datetime' },
|
||||
{ key: 'isSpecial', label: '是否特殊样本', type: 'tabs', options: [
|
||||
{ label: '否', value: 0 }, { label: '是', value: 1 }
|
||||
] },
|
||||
{ key: 'specialRemark', label: '特殊说明', type: 'textarea' },
|
||||
{ key: 'remark', label: '备注', type: 'textarea' }
|
||||
] },
|
||||
{ key: 'payment', title: '缴费信息(只读)', nested: true, readOnly: true, editOnly: true, source: 'cell_storage', fields: [
|
||||
{ key: 'payMode', label: '缴费模式', readOnly: true },
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export default {
|
||||
key: 'sample',
|
||||
resource: 'samples',
|
||||
readOnlySources: ['research_donation'],
|
||||
actions: { specialist: ['view', 'storage-cert', 'insurance'], manager: ['view', 'storage-cert', 'insurance'] },
|
||||
listParams: ({ status } = {}) => ({ status: status || '' }),
|
||||
statusOptions: [{ text: '全部状态', value: '' }],
|
||||
formOptionsParams(sampleId) {
|
||||
return { sampleId: String(sampleId || '').trim() }
|
||||
},
|
||||
initialForm(detail, action) {
|
||||
const handles = (detail?.sections || []).find((section) => section.key === 'market-handles') || {}
|
||||
const handle = action === 'insurance' ? handles.insurance || {} : handles.storageCert || {}
|
||||
const fields = ['handleTime', 'handlerUserId', 'attachments', 'remark']
|
||||
return {
|
||||
handle: Object.fromEntries(fields
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(handle, key))
|
||||
.map((key) => [key, handle[key]]))
|
||||
}
|
||||
},
|
||||
detailSections(detail) {
|
||||
return Array.isArray(detail?.sections) ? detail.sections : []
|
||||
},
|
||||
handleFields: [
|
||||
{ key: 'handleTime', label: '办理时间', type: 'datetime' },
|
||||
{ key: 'handlerUserId', label: '办理人员', type: 'select', optionKey: 'users' },
|
||||
{ key: 'attachments', label: '附件', type: 'upload' },
|
||||
{ key: 'remark', label: '备注', type: 'textarea' }
|
||||
]
|
||||
}
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user