提交
This commit is contained in:
1 parent
2a4767a014
commit
f269a994f4
61 files changed
+27430
-8
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
55329
|
||||
38796
|
||||
@@ -10,6 +10,9 @@ from app.api.operations import router as operations_router
|
||||
from app.api.ethnic import router as ethnic_router
|
||||
from app.api.sample_reception import router as sample_reception_router
|
||||
from app.api.qc import router as qc_router
|
||||
from app.api.research_donation import router as research_donation_router
|
||||
from app.api.production import router as production_router
|
||||
from app.api.pilot_service import router as pilot_service_router
|
||||
|
||||
__all__ = [
|
||||
"public_router",
|
||||
@@ -23,4 +26,7 @@ __all__ = [
|
||||
"ethnic_router",
|
||||
"sample_reception_router",
|
||||
"qc_router",
|
||||
"research_donation_router",
|
||||
"production_router",
|
||||
"pilot_service_router",
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,914 @@
|
||||
"""生产管理 API 路由(7 个子模块:耗材、试剂、SOP文档、工艺工序分类、产出物类型、耗材包、工艺库)"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any, Optional, List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
|
||||
from app.core.response import success_response, error_response
|
||||
from app.crud import consumable as consumable_svc
|
||||
from app.crud import reagent as reagent_svc
|
||||
from app.crud import sop_document as sop_document_svc
|
||||
from app.crud import process_category as process_category_svc
|
||||
from app.crud import output_type as output_type_svc
|
||||
from app.crud import consumable_pack as consumable_pack_svc
|
||||
from app.crud import process as process_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["生产管理"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
return "zkxw"
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
return (sys_user.tenant_no if sys_user else None) or "zkxw"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 耗材
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ConsumableCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
consumable_code: str = Field(..., alias="consumableCode")
|
||||
consumable_name: str = Field(..., alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
description: Optional[str] = None
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ConsumableUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
description: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/consumables")
|
||||
async def list_consumables(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:consumable:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = consumable_svc.list_consumables(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = consumable_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/consumables/all")
|
||||
async def list_all_consumables(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, _ = consumable_svc.list_consumables(db, page=1, page_size=9999, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/consumables")
|
||||
async def create_consumable(
|
||||
body: ConsumableCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = consumable_svc.create_consumable(
|
||||
db,
|
||||
consumable_code=body.consumable_code,
|
||||
consumable_name=body.consumable_name,
|
||||
spec_model=body.spec_model,
|
||||
standard_usage=body.standard_usage,
|
||||
description=body.description,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(consumable_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/consumables/{consumable_id}")
|
||||
async def update_consumable(
|
||||
consumable_id: str,
|
||||
body: ConsumableUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = consumable_svc.update_consumable(
|
||||
db,
|
||||
consumable_id,
|
||||
consumable_code=payload.get("consumable_code"),
|
||||
consumable_name=payload.get("consumable_name"),
|
||||
spec_model=payload.get("spec_model"),
|
||||
standard_usage=payload.get("standard_usage"),
|
||||
description=payload.get("description"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(consumable_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/consumables/{consumable_id}")
|
||||
async def delete_consumable(
|
||||
consumable_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
consumable_svc.soft_delete_consumable(db, consumable_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 试剂
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ReagentCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
reagent_code: str = Field(..., alias="reagentCode")
|
||||
reagent_name: str = Field(..., alias="reagentName")
|
||||
spec: Optional[str] = None
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
unit: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ReagentUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
reagent_code: Optional[str] = Field(None, alias="reagentCode")
|
||||
reagent_name: Optional[str] = Field(None, alias="reagentName")
|
||||
spec: Optional[str] = None
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
unit: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/reagents")
|
||||
async def list_reagents(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:reagent:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = reagent_svc.list_reagents(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = reagent_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/reagents/all")
|
||||
async def list_all_reagents(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, _ = reagent_svc.list_reagents(db, page=1, page_size=9999, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/reagents")
|
||||
async def create_reagent(
|
||||
body: ReagentCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = reagent_svc.create_reagent(
|
||||
db,
|
||||
reagent_code=body.reagent_code,
|
||||
reagent_name=body.reagent_name,
|
||||
spec=body.spec,
|
||||
standard_usage=body.standard_usage,
|
||||
unit=body.unit,
|
||||
description=body.description,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(reagent_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/reagents/{reagent_id}")
|
||||
async def update_reagent(
|
||||
reagent_id: str,
|
||||
body: ReagentUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = reagent_svc.update_reagent(
|
||||
db,
|
||||
reagent_id,
|
||||
reagent_code=payload.get("reagent_code"),
|
||||
reagent_name=payload.get("reagent_name"),
|
||||
spec=payload.get("spec"),
|
||||
standard_usage=payload.get("standard_usage"),
|
||||
unit=payload.get("unit"),
|
||||
description=payload.get("description"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(reagent_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/reagents/{reagent_id}")
|
||||
async def delete_reagent(
|
||||
reagent_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
reagent_svc.soft_delete_reagent(db, reagent_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. SOP文档
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SopDocumentCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
sop_code: str = Field(..., alias="sopCode")
|
||||
sop_name: str = Field(..., alias="sopName")
|
||||
version_no: Optional[str] = Field(None, alias="versionNo")
|
||||
sop_attachments: Optional[str] = Field(None, alias="sopAttachments")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class SopDocumentUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
sop_code: Optional[str] = Field(None, alias="sopCode")
|
||||
sop_name: Optional[str] = Field(None, alias="sopName")
|
||||
version_no: Optional[str] = Field(None, alias="versionNo")
|
||||
sop_attachments: Optional[str] = Field(None, alias="sopAttachments")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/sop-documents")
|
||||
async def list_sop_documents(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:sop-document:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = sop_document_svc.list_sop_documents(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = sop_document_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/sop-documents/all")
|
||||
async def list_all_sop_documents(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, _ = sop_document_svc.list_sop_documents(db, page=1, page_size=9999, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/sop-documents")
|
||||
async def create_sop_document(
|
||||
body: SopDocumentCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = sop_document_svc.create_sop_document(
|
||||
db,
|
||||
sop_code=body.sop_code,
|
||||
sop_name=body.sop_name,
|
||||
version_no=body.version_no,
|
||||
sop_attachments=body.sop_attachments,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(sop_document_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/sop-documents/{sop_id}")
|
||||
async def update_sop_document(
|
||||
sop_id: str,
|
||||
body: SopDocumentUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = sop_document_svc.update_sop_document(
|
||||
db,
|
||||
sop_id,
|
||||
sop_code=payload.get("sop_code"),
|
||||
sop_name=payload.get("sop_name"),
|
||||
version_no=payload.get("version_no"),
|
||||
sop_attachments=payload.get("sop_attachments"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(sop_document_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/sop-documents/{sop_id}")
|
||||
async def delete_sop_document(
|
||||
sop_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
sop_document_svc.soft_delete_sop_document(db, sop_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 工艺工序分类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProcessCategoryCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
category_code: str = Field(..., alias="categoryCode")
|
||||
category_name: str = Field(..., alias="categoryName")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ProcessCategoryUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
category_code: Optional[str] = Field(None, alias="categoryCode")
|
||||
category_name: Optional[str] = Field(None, alias="categoryName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/process-categories")
|
||||
async def list_process_categories(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:process-category:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = process_category_svc.list_process_categories(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = process_category_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/process-categories/all")
|
||||
async def list_all_process_categories(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, _ = process_category_svc.list_process_categories(db, page=1, page_size=9999, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/process-categories")
|
||||
async def create_process_category(
|
||||
body: ProcessCategoryCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = process_category_svc.create_process_category(
|
||||
db,
|
||||
category_code=body.category_code,
|
||||
category_name=body.category_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(process_category_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/process-categories/{category_id}")
|
||||
async def update_process_category(
|
||||
category_id: str,
|
||||
body: ProcessCategoryUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = process_category_svc.update_process_category(
|
||||
db,
|
||||
category_id,
|
||||
category_code=payload.get("category_code"),
|
||||
category_name=payload.get("category_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(process_category_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/process-categories/{category_id}")
|
||||
async def delete_process_category(
|
||||
category_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
process_category_svc.soft_delete_process_category(db, category_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 工艺产出物类型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OutputTypeCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
type_code: str = Field(..., alias="typeCode")
|
||||
type_name: str = Field(..., alias="typeName")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class OutputTypeUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
type_code: Optional[str] = Field(None, alias="typeCode")
|
||||
type_name: Optional[str] = Field(None, alias="typeName")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/output-types")
|
||||
async def list_output_types(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:output-type:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = output_type_svc.list_output_types(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = output_type_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/output-types/all")
|
||||
async def list_all_output_types(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, _ = output_type_svc.list_output_types(db, page=1, page_size=9999, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.post("/output-types")
|
||||
async def create_output_type(
|
||||
body: OutputTypeCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = output_type_svc.create_output_type(
|
||||
db,
|
||||
type_code=body.type_code,
|
||||
type_name=body.type_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(output_type_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/output-types/{type_id}")
|
||||
async def update_output_type(
|
||||
type_id: str,
|
||||
body: OutputTypeUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
obj = output_type_svc.update_output_type(
|
||||
db,
|
||||
type_id,
|
||||
type_code=payload.get("type_code"),
|
||||
type_name=payload.get("type_name"),
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(output_type_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/output-types/{type_id}")
|
||||
async def delete_output_type(
|
||||
type_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
output_type_svc.soft_delete_output_type(db, type_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. 耗材包(主 + 子项)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PackItemInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ConsumablePackCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
pack_code: str = Field(..., alias="packCode")
|
||||
pack_name: str = Field(..., alias="packName")
|
||||
usage_scene: Optional[str] = Field(None, alias="usageScene")
|
||||
pack_type: Optional[str] = Field(None, alias="packType")
|
||||
status: Optional[str] = "1"
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
items: List[PackItemInput] = []
|
||||
|
||||
|
||||
class ConsumablePackUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
pack_code: Optional[str] = Field(None, alias="packCode")
|
||||
pack_name: Optional[str] = Field(None, alias="packName")
|
||||
usage_scene: Optional[str] = Field(None, alias="usageScene")
|
||||
pack_type: Optional[str] = Field(None, alias="packType")
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
items: Optional[List[PackItemInput]] = None
|
||||
|
||||
|
||||
@router.get("/consumable-packs")
|
||||
async def list_consumable_packs(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:consumable-pack:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = consumable_pack_svc.list_consumable_packs(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = consumable_pack_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/consumable-packs/all")
|
||||
async def list_all_consumable_packs(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = consumable_pack_svc.list_all_packs(db, tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.get("/consumable-packs/{pack_id}")
|
||||
async def get_consumable_pack_detail(
|
||||
pack_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
detail = consumable_pack_svc.get_pack_detail(db, pack_id)
|
||||
if not detail:
|
||||
return error_response(404, "耗材包不存在")
|
||||
return success_response(detail)
|
||||
|
||||
|
||||
@router.post("/consumable-packs")
|
||||
async def create_consumable_pack(
|
||||
body: ConsumablePackCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body.items] if body.items else None
|
||||
obj = consumable_pack_svc.create_consumable_pack(
|
||||
db,
|
||||
pack_code=body.pack_code,
|
||||
pack_name=body.pack_name,
|
||||
usage_scene=body.usage_scene,
|
||||
pack_type=body.pack_type,
|
||||
status=int(body.status) if body.status is not None else None,
|
||||
items=items_data,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(consumable_pack_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/consumable-packs/{pack_id}")
|
||||
async def update_consumable_pack(
|
||||
pack_id: str,
|
||||
body: ConsumablePackUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
items_data = [it.model_dump(by_alias=False) for it in body.items] if body.items is not None else None
|
||||
obj = consumable_pack_svc.update_consumable_pack(
|
||||
db,
|
||||
pack_id,
|
||||
pack_code=payload.get("pack_code"),
|
||||
pack_name=payload.get("pack_name"),
|
||||
usage_scene=payload.get("usage_scene"),
|
||||
pack_type=payload.get("pack_type"),
|
||||
status=int(payload["status"]) if "status" in payload else None,
|
||||
items=items_data,
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(consumable_pack_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/consumable-packs/{pack_id}")
|
||||
async def delete_consumable_pack(
|
||||
pack_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
consumable_pack_svc.soft_delete_consumable_pack(db, pack_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 工艺库(主 + 3 个子表:试剂、耗材、产出物)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProcessReagentInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
reagent_code: Optional[str] = Field(None, alias="reagentCode")
|
||||
reagent_name: Optional[str] = Field(None, alias="reagentName")
|
||||
spec: Optional[str] = None
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
unit: Optional[str] = None
|
||||
is_key_material: Optional[str] = Field("0", alias="isKeyMaterial")
|
||||
|
||||
|
||||
class ProcessConsumableInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
is_required: Optional[str] = Field("1", alias="isRequired")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ProcessOutputInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
|
||||
output_name: Optional[str] = Field(None, alias="outputName")
|
||||
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
|
||||
|
||||
|
||||
class ProcessCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
process_code: str = Field(..., alias="processCode")
|
||||
category_id: Optional[str] = Field(None, alias="categoryId")
|
||||
process_name: str = Field(..., alias="processName")
|
||||
description: Optional[str] = None
|
||||
equipment_name: Optional[str] = Field(None, alias="equipmentName")
|
||||
estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
|
||||
sop_ids: Optional[str] = Field(None, alias="sopIds")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
reagents: List[ProcessReagentInput] = []
|
||||
consumables: List[ProcessConsumableInput] = []
|
||||
outputs: List[ProcessOutputInput] = []
|
||||
|
||||
|
||||
class ProcessUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
process_code: Optional[str] = Field(None, alias="processCode")
|
||||
category_id: Optional[str] = Field(None, alias="categoryId")
|
||||
process_name: Optional[str] = Field(None, alias="processName")
|
||||
description: Optional[str] = None
|
||||
equipment_name: Optional[str] = Field(None, alias="equipmentName")
|
||||
estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
|
||||
sop_ids: Optional[str] = Field(None, alias="sopIds")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
reagents: Optional[List[ProcessReagentInput]] = None
|
||||
consumables: Optional[List[ProcessConsumableInput]] = None
|
||||
outputs: Optional[List[ProcessOutputInput]] = None
|
||||
|
||||
|
||||
@router.get("/processes")
|
||||
async def list_processes(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_menu_code("production:process:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = process_svc.list_processes(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
tp = process_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/processes/all")
|
||||
async def list_all_processes(
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = process_svc.list_all_processes(db, tenant_no)
|
||||
return success_response(items)
|
||||
|
||||
|
||||
@router.get("/processes/{process_id}")
|
||||
async def get_process_detail(
|
||||
process_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
detail = process_svc.get_process_detail(db, process_id)
|
||||
if not detail:
|
||||
return error_response(404, "工艺不存在")
|
||||
return success_response(detail)
|
||||
|
||||
|
||||
@router.post("/processes")
|
||||
async def create_process(
|
||||
body: ProcessCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
reagents_data = [r.model_dump(by_alias=False) for r in body.reagents] if body.reagents else None
|
||||
consumables_data = [c.model_dump(by_alias=False) for c in body.consumables] if body.consumables else None
|
||||
outputs_data = [o.model_dump(by_alias=False) for o in body.outputs] if body.outputs else None
|
||||
# Convert string fields to appropriate types for sub-records
|
||||
for rd in (reagents_data or []):
|
||||
if "is_key_material" in rd and isinstance(rd["is_key_material"], str):
|
||||
rd["is_key_material"] = int(rd["is_key_material"])
|
||||
for cd in (consumables_data or []):
|
||||
if "is_required" in cd and isinstance(cd["is_required"], str):
|
||||
cd["is_required"] = int(cd["is_required"])
|
||||
obj = process_svc.create_process(
|
||||
db,
|
||||
process_code=body.process_code,
|
||||
category_id=body.category_id,
|
||||
process_name=body.process_name,
|
||||
description=body.description,
|
||||
equipment_name=body.equipment_name,
|
||||
estimated_duration=body.estimated_duration,
|
||||
sop_ids=body.sop_ids,
|
||||
reagents=reagents_data,
|
||||
consumables=consumables_data,
|
||||
outputs=outputs_data,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(process_svc._row_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/processes/{process_id}")
|
||||
async def update_process(
|
||||
process_id: str,
|
||||
body: ProcessUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
|
||||
reagents_data = [r.model_dump(by_alias=False) for r in body.reagents] if body.reagents is not None else None
|
||||
consumables_data = [c.model_dump(by_alias=False) for c in body.consumables] if body.consumables is not None else None
|
||||
outputs_data = [o.model_dump(by_alias=False) for o in body.outputs] if body.outputs is not None else None
|
||||
# Convert string fields to appropriate types for sub-records
|
||||
for rd in (reagents_data or []):
|
||||
if "is_key_material" in rd and isinstance(rd["is_key_material"], str):
|
||||
rd["is_key_material"] = int(rd["is_key_material"])
|
||||
for cd in (consumables_data or []):
|
||||
if "is_required" in cd and isinstance(cd["is_required"], str):
|
||||
cd["is_required"] = int(cd["is_required"])
|
||||
obj = process_svc.update_process(
|
||||
db,
|
||||
process_id,
|
||||
process_code=payload.get("process_code"),
|
||||
category_id=payload.get("category_id"),
|
||||
process_name=payload.get("process_name"),
|
||||
description=payload.get("description"),
|
||||
equipment_name=payload.get("equipment_name"),
|
||||
estimated_duration=payload.get("estimated_duration"),
|
||||
sop_ids=payload.get("sop_ids"),
|
||||
reagents=reagents_data,
|
||||
consumables=consumables_data,
|
||||
outputs=outputs_data,
|
||||
order_num=payload.get("order_num"),
|
||||
remark=payload.get("remark"),
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
return success_response(process_svc._row_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/processes/{process_id}")
|
||||
async def delete_process(
|
||||
process_id: str,
|
||||
_user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
process_svc.soft_delete_process(db, process_id)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -0,0 +1,385 @@
|
||||
"""科研捐赠管理 — 捐赠单 CRUD / 审核 / 状态变更 / 转换 API"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import research_donation as rd_svc
|
||||
from app.models.models import SysUser
|
||||
from app.models.research_donation import BizResearchDonation
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
|
||||
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DonationCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
donation_no: str = Field(..., alias="donationNo")
|
||||
donation_status: Optional[str] = Field("dsx", alias="donationStatus")
|
||||
donation_date: Optional[str] = Field(None, alias="donationDate")
|
||||
source_type: Optional[str] = Field("direct", alias="sourceType")
|
||||
source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId")
|
||||
consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
|
||||
attachment_urls: Optional[str] = Field(None, alias="attachmentUrls")
|
||||
remark: Optional[str] = None
|
||||
party: Optional[dict] = None
|
||||
collection: Optional[dict] = None
|
||||
samples: Optional[list] = None
|
||||
|
||||
|
||||
class DonationUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
donation_no: Optional[str] = Field(None, alias="donationNo")
|
||||
donation_status: Optional[str] = Field(None, alias="donationStatus")
|
||||
donation_date: Optional[str] = Field(None, alias="donationDate")
|
||||
source_type: Optional[str] = Field(None, alias="sourceType")
|
||||
source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId")
|
||||
consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
|
||||
attachment_urls: Optional[str] = Field(None, alias="attachmentUrls")
|
||||
remark: Optional[str] = None
|
||||
party: Optional[dict] = None
|
||||
collection: Optional[dict] = None
|
||||
samples: Optional[list] = None
|
||||
|
||||
|
||||
class BatchDeleteBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
ids: List[str] = Field(..., alias="ids", min_length=1)
|
||||
|
||||
|
||||
class AuditSubmitBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
audit_status: str = Field(..., alias="auditStatus")
|
||||
audit_remark: Optional[str] = Field(None, alias="auditRemark")
|
||||
|
||||
|
||||
class StatusChangeBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
new_status: str = Field(..., alias="newStatus")
|
||||
desc: Optional[str] = Field(None, alias="desc")
|
||||
time_val: Optional[str] = Field(None, alias="timeVal")
|
||||
|
||||
|
||||
class ConvertFromStorageBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
protocol_id: str = Field(..., alias="protocolId")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 列表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/research-donations")
|
||||
async def list_research_donations(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(20, ge=1, le=200, alias="pageSize"),
|
||||
keyword: Optional[str] = Query(None, alias="keyword"),
|
||||
donationStatus: Optional[str] = Query(None, alias="donationStatus"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
collector_user_id = _get_current_user_id(user)
|
||||
result = rd_svc.list_donations(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
donation_status=donationStatus,
|
||||
collector_user_id=collector_user_id,
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 详情
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/research-donations/{donation_id}")
|
||||
async def get_research_donation_detail(
|
||||
donation_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = rd_svc.get_donation_detail(db, donation_id, tenant_no)
|
||||
if not result:
|
||||
return error_response(404, "捐赠单不存在或无权查看")
|
||||
return success_response(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 创建
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/research-donations")
|
||||
async def create_research_donation(
|
||||
body: DonationCreateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
data = body.model_dump(exclude_none=False, by_alias=True)
|
||||
data["collector_user_id"] = user_id
|
||||
obj = rd_svc.create_donation(
|
||||
db,
|
||||
data=data,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(rd_svc._donation_to_dict(obj), message="创建成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 更新
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.put("/research-donations/{donation_id}")
|
||||
async def update_research_donation(
|
||||
donation_id: str,
|
||||
body: DonationUpdateBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
data = body.model_dump(exclude_none=False, by_alias=True)
|
||||
obj = rd_svc.update_donation(
|
||||
db,
|
||||
donation_id,
|
||||
data=data,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(rd_svc._donation_to_dict(obj), message="更新成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete("/research-donations/{donation_id}")
|
||||
async def delete_research_donation(
|
||||
donation_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
rd_svc.delete_donation(
|
||||
db,
|
||||
donation_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. 批量删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/research-donations/batch-delete")
|
||||
async def batch_delete_research_donations(
|
||||
body: BatchDeleteBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
count = rd_svc.batch_delete_donations(
|
||||
db,
|
||||
ids=body.ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response({"count": count}, message=f"已成功删除 {count} 条")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 审核列表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/research-donations/audits")
|
||||
async def list_research_donation_audits(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(20, ge=1, le=200, alias="pageSize"),
|
||||
keyword: Optional[str] = Query(None, alias="keyword"),
|
||||
auditStatus: Optional[str] = Query(None, alias="auditStatus"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
result = rd_svc.list_audits(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
audit_status=auditStatus,
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. 提交审核
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/research-donations/{donation_id}/audit")
|
||||
async def submit_donation_audit(
|
||||
donation_id: str,
|
||||
body: AuditSubmitBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
try:
|
||||
rd_svc.submit_audit(
|
||||
db,
|
||||
donation_id,
|
||||
audit_status=body.audit_status,
|
||||
audit_remark=body.audit_remark,
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
d = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(BizResearchDonation.id == donation_id)
|
||||
.first()
|
||||
)
|
||||
dn = (d.donation_no if d else "") or ""
|
||||
return success_response({"donationNo": dn}, message="审核已提交")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. 状态变更
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/research-donations/{donation_id}/status-change")
|
||||
async def change_donation_status(
|
||||
donation_id: str,
|
||||
body: StatusChangeBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
obj = rd_svc.change_status(
|
||||
db,
|
||||
donation_id,
|
||||
new_status=body.new_status,
|
||||
desc=body.desc,
|
||||
time_val=body.time_val,
|
||||
user_id=user_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(rd_svc._donation_to_dict(obj), message="状态变更成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. 细胞存储 → 科研捐赠
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/research-donations/convert-from-storage")
|
||||
async def convert_from_storage(
|
||||
body: ConvertFromStorageBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
obj = rd_svc.convert_from_storage(
|
||||
db,
|
||||
body.protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(rd_svc._donation_to_dict(obj), message="转换成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. 科研捐赠 → 细胞存储
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/research-donations/{donation_id}/convert-to-storage")
|
||||
async def convert_to_storage(
|
||||
donation_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
obj = rd_svc.convert_to_storage(
|
||||
db,
|
||||
donation_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
return success_response(rd_svc._donation_to_dict(obj), message="转换成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -0,0 +1,181 @@
|
||||
"""耗材 CRUD(biz_consumable)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizConsumable
|
||||
|
||||
|
||||
def _row_to_dict(c: BizConsumable) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c.id,
|
||||
"consumableCode": c.consumable_code,
|
||||
"consumableName": c.consumable_name,
|
||||
"specModel": c.spec_model or "",
|
||||
"standardUsage": c.standard_usage or "",
|
||||
"description": c.description or "",
|
||||
"tenantNo": c.tenant_no,
|
||||
"orderNum": c.order_num if c.order_num is not None else 0,
|
||||
"remark": c.remark or "",
|
||||
"createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
|
||||
"updateTime": c.update_time.strftime("%Y-%m-%d %H:%M:%S") if c.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_consumables(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizConsumable).filter(BizConsumable.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizConsumable.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizConsumable.consumable_code.like(like),
|
||||
BizConsumable.consumable_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizConsumable.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizConsumable.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_consumable(db: Session, consumable_id: str) -> Optional[BizConsumable]:
|
||||
return (
|
||||
db.query(BizConsumable)
|
||||
.filter(and_(BizConsumable.id == consumable_id, BizConsumable.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def consumable_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizConsumable).filter(
|
||||
and_(BizConsumable.consumable_code == code.strip(), BizConsumable.tenant_no == tenant_no, BizConsumable.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizConsumable.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_consumable(
|
||||
db: Session,
|
||||
*,
|
||||
consumable_code: str,
|
||||
consumable_name: str,
|
||||
spec_model: Optional[str] = None,
|
||||
standard_usage: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizConsumable:
|
||||
cc = (consumable_code or "").strip()
|
||||
if not cc:
|
||||
raise ValueError("耗材编码不能为空")
|
||||
if consumable_code_exists(db, cc, tenant_no):
|
||||
raise ValueError("耗材编码已存在")
|
||||
obj = BizConsumable(
|
||||
id=generate_uuid(),
|
||||
consumable_code=cc,
|
||||
consumable_name=(consumable_name or "").strip() or cc,
|
||||
spec_model=(spec_model or "").strip() or None,
|
||||
standard_usage=(standard_usage or "").strip() or None,
|
||||
description=(description or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
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_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_consumable(
|
||||
db: Session,
|
||||
consumable_id: str,
|
||||
*,
|
||||
consumable_code: Optional[str] = None,
|
||||
consumable_name: Optional[str] = None,
|
||||
spec_model: Optional[str] = None,
|
||||
standard_usage: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizConsumable:
|
||||
obj = get_consumable(db, consumable_id)
|
||||
if not obj:
|
||||
raise ValueError("耗材不存在")
|
||||
if consumable_code is not None:
|
||||
new_code = consumable_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("耗材编码不能为空")
|
||||
if new_code != obj.consumable_code and consumable_code_exists(db, new_code, obj.tenant_no, exclude_id=consumable_id):
|
||||
raise ValueError("耗材编码已存在")
|
||||
obj.consumable_code = new_code
|
||||
if consumable_name is not None:
|
||||
obj.consumable_name = (consumable_name or "").strip() or obj.consumable_name
|
||||
if spec_model is not None:
|
||||
obj.spec_model = (spec_model or "").strip() or None
|
||||
if standard_usage is not None:
|
||||
obj.standard_usage = (standard_usage or "").strip() or None
|
||||
if description is not None:
|
||||
obj.description = (description or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_consumable(db: Session, consumable_id: str) -> None:
|
||||
obj = get_consumable(db, consumable_id)
|
||||
if not obj:
|
||||
raise ValueError("耗材不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -0,0 +1,289 @@
|
||||
"""耗材包 CRUD(biz_consumable_pack + biz_consumable_pack_item)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizConsumablePack, BizConsumablePackItem
|
||||
|
||||
|
||||
def _item_row_to_dict(item: BizConsumablePackItem) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": item.id,
|
||||
"packId": item.pack_id,
|
||||
"consumableCode": item.consumable_code,
|
||||
"consumableName": item.consumable_name,
|
||||
"specModel": item.spec_model or "",
|
||||
"standardUsage": item.standard_usage or "",
|
||||
"remark": item.remark or "",
|
||||
}
|
||||
|
||||
|
||||
def _row_to_dict(p: BizConsumablePack) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": p.id,
|
||||
"packCode": p.pack_code,
|
||||
"packName": p.pack_name,
|
||||
"usageScene": p.usage_scene or "",
|
||||
"packType": p.pack_type or "",
|
||||
"status": p.status if p.status is not None else 0,
|
||||
"tenantNo": p.tenant_no,
|
||||
"orderNum": p.order_num if p.order_num is not None else 0,
|
||||
"remark": p.remark or "",
|
||||
"createTime": p.create_time.strftime("%Y-%m-%d %H:%M:%S") if p.create_time else "",
|
||||
"updateTime": p.update_time.strftime("%Y-%m-%d %H:%M:%S") if p.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_consumable_packs(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizConsumablePack).filter(BizConsumablePack.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizConsumablePack.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizConsumablePack.pack_code.like(like),
|
||||
BizConsumablePack.pack_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizConsumablePack.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizConsumablePack.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def list_all_packs(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizConsumablePack)
|
||||
.filter(and_(BizConsumablePack.tenant_no == tenant_no, BizConsumablePack.del_flag == 0))
|
||||
.order_by(BizConsumablePack.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
def get_consumable_pack(db: Session, pack_id: str) -> Optional[BizConsumablePack]:
|
||||
return (
|
||||
db.query(BizConsumablePack)
|
||||
.filter(and_(BizConsumablePack.id == pack_id, BizConsumablePack.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_pack_detail(db: Session, pack_id: str) -> Optional[Dict[str, Any]]:
|
||||
pack = get_consumable_pack(db, pack_id)
|
||||
if not pack:
|
||||
return None
|
||||
result = _row_to_dict(pack)
|
||||
items = (
|
||||
db.query(BizConsumablePackItem)
|
||||
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
|
||||
.order_by(BizConsumablePackItem.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
result["items"] = [_item_row_to_dict(i) for i in items]
|
||||
return result
|
||||
|
||||
|
||||
def pack_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizConsumablePack).filter(
|
||||
and_(BizConsumablePack.pack_code == code.strip(), BizConsumablePack.tenant_no == tenant_no, BizConsumablePack.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizConsumablePack.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_consumable_pack(
|
||||
db: Session,
|
||||
*,
|
||||
pack_code: str,
|
||||
pack_name: str,
|
||||
usage_scene: Optional[str] = None,
|
||||
pack_type: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizConsumablePack:
|
||||
pc = (pack_code or "").strip()
|
||||
if not pc:
|
||||
raise ValueError("耗材包编码不能为空")
|
||||
if pack_code_exists(db, pc, tenant_no):
|
||||
raise ValueError("耗材包编码已存在")
|
||||
now = datetime.now()
|
||||
obj = BizConsumablePack(
|
||||
id=generate_uuid(),
|
||||
pack_code=pc,
|
||||
pack_name=(pack_name or "").strip() or pc,
|
||||
usage_scene=(usage_scene or "").strip() or None,
|
||||
pack_type=(pack_type or "").strip() or None,
|
||||
status=status if status is not None else 0,
|
||||
tenant_no=tenant_no,
|
||||
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_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
|
||||
if items:
|
||||
for it in items:
|
||||
item_obj = BizConsumablePackItem(
|
||||
id=generate_uuid(),
|
||||
pack_id=obj.id,
|
||||
consumable_code=(it.get("consumable_code") or "").strip(),
|
||||
consumable_name=(it.get("consumable_name") or "").strip(),
|
||||
spec_model=(it.get("spec_model") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(item_obj)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_consumable_pack(
|
||||
db: Session,
|
||||
pack_id: str,
|
||||
*,
|
||||
pack_code: Optional[str] = None,
|
||||
pack_name: Optional[str] = None,
|
||||
usage_scene: Optional[str] = None,
|
||||
pack_type: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
items: Optional[List[Dict[str, Any]]] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizConsumablePack:
|
||||
obj = get_consumable_pack(db, pack_id)
|
||||
if not obj:
|
||||
raise ValueError("耗材包不存在")
|
||||
if pack_code is not None:
|
||||
new_code = pack_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("耗材包编码不能为空")
|
||||
if new_code != obj.pack_code and pack_code_exists(db, new_code, obj.tenant_no, exclude_id=pack_id):
|
||||
raise ValueError("耗材包编码已存在")
|
||||
obj.pack_code = new_code
|
||||
if pack_name is not None:
|
||||
obj.pack_name = (pack_name or "").strip() or obj.pack_name
|
||||
if usage_scene is not None:
|
||||
obj.usage_scene = (usage_scene or "").strip() or None
|
||||
if pack_type is not None:
|
||||
obj.pack_type = (pack_type or "").strip() or None
|
||||
if status is not None:
|
||||
obj.status = status
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
|
||||
if items is not None:
|
||||
existing = (
|
||||
db.query(BizConsumablePackItem)
|
||||
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
|
||||
.all()
|
||||
)
|
||||
now = datetime.now()
|
||||
for ei in existing:
|
||||
ei.del_flag = 1
|
||||
ei.update_time = now
|
||||
db.add(ei)
|
||||
|
||||
for it in items:
|
||||
item_obj = BizConsumablePackItem(
|
||||
id=generate_uuid(),
|
||||
pack_id=pack_id,
|
||||
consumable_code=(it.get("consumable_code") or "").strip(),
|
||||
consumable_name=(it.get("consumable_name") or "").strip(),
|
||||
spec_model=(it.get("spec_model") or "").strip() or None,
|
||||
standard_usage=(it.get("standard_usage") or "").strip() or None,
|
||||
remark=(it.get("remark") or "").strip() or None,
|
||||
tenant_no=obj.tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(item_obj)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_consumable_pack(db: Session, pack_id: str) -> None:
|
||||
obj = get_consumable_pack(db, pack_id)
|
||||
if not obj:
|
||||
raise ValueError("耗材包不存在")
|
||||
now = datetime.now()
|
||||
obj.del_flag = 1
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
|
||||
items = (
|
||||
db.query(BizConsumablePackItem)
|
||||
.filter(and_(BizConsumablePackItem.pack_id == pack_id, BizConsumablePackItem.del_flag == 0))
|
||||
.all()
|
||||
)
|
||||
for it in items:
|
||||
it.del_flag = 1
|
||||
it.update_time = now
|
||||
db.add(it)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -0,0 +1,163 @@
|
||||
"""工艺产出物类型 CRUD(biz_output_type)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizOutputType
|
||||
|
||||
|
||||
def _row_to_dict(t: BizOutputType) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
"typeCode": t.type_code,
|
||||
"typeName": t.type_name,
|
||||
"tenantNo": t.tenant_no,
|
||||
"orderNum": t.order_num if t.order_num is not None else 0,
|
||||
"remark": t.remark or "",
|
||||
"createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
|
||||
"updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_output_types(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizOutputType).filter(BizOutputType.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizOutputType.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizOutputType.type_code.like(like),
|
||||
BizOutputType.type_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizOutputType.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizOutputType.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_output_type(db: Session, type_id: str) -> Optional[BizOutputType]:
|
||||
return (
|
||||
db.query(BizOutputType)
|
||||
.filter(and_(BizOutputType.id == type_id, BizOutputType.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def type_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizOutputType).filter(
|
||||
and_(BizOutputType.type_code == code.strip(), BizOutputType.tenant_no == tenant_no, BizOutputType.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizOutputType.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_output_type(
|
||||
db: Session,
|
||||
*,
|
||||
type_code: str,
|
||||
type_name: str,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizOutputType:
|
||||
tc = (type_code or "").strip()
|
||||
if not tc:
|
||||
raise ValueError("类型编码不能为空")
|
||||
if type_code_exists(db, tc, tenant_no):
|
||||
raise ValueError("类型编码已存在")
|
||||
obj = BizOutputType(
|
||||
id=generate_uuid(),
|
||||
type_code=tc,
|
||||
type_name=(type_name or "").strip() or tc,
|
||||
tenant_no=tenant_no,
|
||||
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_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_output_type(
|
||||
db: Session,
|
||||
type_id: str,
|
||||
*,
|
||||
type_code: Optional[str] = None,
|
||||
type_name: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizOutputType:
|
||||
obj = get_output_type(db, type_id)
|
||||
if not obj:
|
||||
raise ValueError("产出物类型不存在")
|
||||
if type_code is not None:
|
||||
new_code = type_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("类型编码不能为空")
|
||||
if new_code != obj.type_code and type_code_exists(db, new_code, obj.tenant_no, exclude_id=type_id):
|
||||
raise ValueError("类型编码已存在")
|
||||
obj.type_code = new_code
|
||||
if type_name is not None:
|
||||
obj.type_name = (type_name or "").strip() or obj.type_name
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_output_type(db: Session, type_id: str) -> None:
|
||||
obj = get_output_type(db, type_id)
|
||||
if not obj:
|
||||
raise ValueError("产出物类型不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
"""工艺库 CRUD(biz_process + biz_process_reagent/consumable/output)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizProcess,
|
||||
BizProcessConsumable,
|
||||
BizProcessOutput,
|
||||
BizProcessReagent,
|
||||
)
|
||||
|
||||
|
||||
def _reagent_row_to_dict(r: BizProcessReagent) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"processId": r.process_id,
|
||||
"reagentCode": r.reagent_code,
|
||||
"reagentName": r.reagent_name,
|
||||
"spec": r.spec or "",
|
||||
"standardUsage": r.standard_usage or "",
|
||||
"unit": r.unit or "",
|
||||
"isKeyMaterial": r.is_key_material if r.is_key_material is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def _consumable_row_to_dict(c: BizProcessConsumable) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c.id,
|
||||
"processId": c.process_id,
|
||||
"consumableCode": c.consumable_code,
|
||||
"consumableName": c.consumable_name,
|
||||
"specModel": c.spec_model or "",
|
||||
"standardUsage": c.standard_usage or "",
|
||||
"isRequired": c.is_required if c.is_required is not None else 0,
|
||||
"remark": c.remark or "",
|
||||
}
|
||||
|
||||
|
||||
def _output_row_to_dict(o: BizProcessOutput) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": o.id,
|
||||
"processId": o.process_id,
|
||||
"outputTypeId": o.output_type_id or "",
|
||||
"outputName": o.output_name or "",
|
||||
"outputBarcode": o.output_barcode or "",
|
||||
}
|
||||
|
||||
|
||||
def _row_to_dict(p: BizProcess) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": p.id,
|
||||
"processCode": p.process_code,
|
||||
"categoryId": p.category_id or "",
|
||||
"processName": p.process_name,
|
||||
"description": p.description or "",
|
||||
"equipmentName": p.equipment_name or "",
|
||||
"estimatedDuration": p.estimated_duration or "",
|
||||
"sopIds": p.sop_ids or "",
|
||||
"tenantNo": p.tenant_no,
|
||||
"orderNum": p.order_num if p.order_num is not None else 0,
|
||||
"remark": p.remark or "",
|
||||
"createTime": p.create_time.strftime("%Y-%m-%d %H:%M:%S") if p.create_time else "",
|
||||
"updateTime": p.update_time.strftime("%Y-%m-%d %H:%M:%S") if p.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_processes(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizProcess).filter(BizProcess.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizProcess.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizProcess.process_code.like(like),
|
||||
BizProcess.process_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizProcess.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizProcess.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def list_all_processes(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizProcess)
|
||||
.filter(and_(BizProcess.tenant_no == tenant_no, BizProcess.del_flag == 0))
|
||||
.order_by(BizProcess.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
def get_process(db: Session, process_id: str) -> Optional[BizProcess]:
|
||||
return (
|
||||
db.query(BizProcess)
|
||||
.filter(and_(BizProcess.id == process_id, BizProcess.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_process_detail(db: Session, process_id: str) -> Optional[Dict[str, Any]]:
|
||||
proc = get_process(db, process_id)
|
||||
if not proc:
|
||||
return None
|
||||
result = _row_to_dict(proc)
|
||||
|
||||
reagents = (
|
||||
db.query(BizProcessReagent)
|
||||
.filter(and_(BizProcessReagent.process_id == process_id, BizProcessReagent.del_flag == 0))
|
||||
.order_by(BizProcessReagent.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
consumables = (
|
||||
db.query(BizProcessConsumable)
|
||||
.filter(and_(BizProcessConsumable.process_id == process_id, BizProcessConsumable.del_flag == 0))
|
||||
.order_by(BizProcessConsumable.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
outputs = (
|
||||
db.query(BizProcessOutput)
|
||||
.filter(and_(BizProcessOutput.process_id == process_id, BizProcessOutput.del_flag == 0))
|
||||
.order_by(BizProcessOutput.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
result["reagents"] = [_reagent_row_to_dict(r) for r in reagents]
|
||||
result["consumables"] = [_consumable_row_to_dict(c) for c in consumables]
|
||||
result["outputs"] = [_output_row_to_dict(o) for o in outputs]
|
||||
return result
|
||||
|
||||
|
||||
def process_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizProcess).filter(
|
||||
and_(BizProcess.process_code == code.strip(), BizProcess.tenant_no == tenant_no, BizProcess.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizProcess.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def _create_sub_reagent(db: Session, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime, data: Dict[str, Any]) -> None:
|
||||
obj = BizProcessReagent(
|
||||
id=generate_uuid(),
|
||||
process_id=process_id,
|
||||
reagent_code=(data.get("reagent_code") or "").strip(),
|
||||
reagent_name=(data.get("reagent_name") or "").strip(),
|
||||
spec=(data.get("spec") or "").strip() or None,
|
||||
standard_usage=(data.get("standard_usage") or "").strip() or None,
|
||||
unit=(data.get("unit") or "").strip() or None,
|
||||
is_key_material=data.get("is_key_material", 0),
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
|
||||
def _create_sub_consumable(db: Session, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime, data: Dict[str, Any]) -> None:
|
||||
obj = BizProcessConsumable(
|
||||
id=generate_uuid(),
|
||||
process_id=process_id,
|
||||
consumable_code=(data.get("consumable_code") or "").strip(),
|
||||
consumable_name=(data.get("consumable_name") or "").strip(),
|
||||
spec_model=(data.get("spec_model") or "").strip() or None,
|
||||
standard_usage=(data.get("standard_usage") or "").strip() or None,
|
||||
is_required=data.get("is_required", 0),
|
||||
remark=(data.get("remark") or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
|
||||
def _create_sub_output(db: Session, process_id: str, tenant_no: str, user_id: Optional[str], now: datetime, data: Dict[str, Any]) -> None:
|
||||
obj = BizProcessOutput(
|
||||
id=generate_uuid(),
|
||||
process_id=process_id,
|
||||
output_type_id=(data.get("output_type_id") or "").strip() or None,
|
||||
output_name=(data.get("output_name") or "").strip() or None,
|
||||
output_barcode=(data.get("output_barcode") or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
|
||||
def create_process(
|
||||
db: Session,
|
||||
*,
|
||||
process_code: str,
|
||||
process_name: str,
|
||||
category_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
equipment_name: Optional[str] = None,
|
||||
estimated_duration: Optional[str] = None,
|
||||
sop_ids: Optional[str] = None,
|
||||
reagents: Optional[List[Dict[str, Any]]] = None,
|
||||
consumables: Optional[List[Dict[str, Any]]] = None,
|
||||
outputs: Optional[List[Dict[str, Any]]] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizProcess:
|
||||
pc = (process_code or "").strip()
|
||||
if not pc:
|
||||
raise ValueError("工艺编码不能为空")
|
||||
if process_code_exists(db, pc, tenant_no):
|
||||
raise ValueError("工艺编码已存在")
|
||||
now = datetime.now()
|
||||
obj = BizProcess(
|
||||
id=generate_uuid(),
|
||||
process_code=pc,
|
||||
process_name=(process_name or "").strip() or pc,
|
||||
category_id=(category_id or "").strip() or None,
|
||||
description=(description or "").strip() or None,
|
||||
equipment_name=(equipment_name or "").strip() or None,
|
||||
estimated_duration=(estimated_duration or "").strip() or None,
|
||||
sop_ids=(sop_ids or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
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_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
|
||||
if reagents:
|
||||
for r in reagents:
|
||||
_create_sub_reagent(db, obj.id, tenant_no, user_id, now, r)
|
||||
if consumables:
|
||||
for c in consumables:
|
||||
_create_sub_consumable(db, obj.id, tenant_no, user_id, now, c)
|
||||
if outputs:
|
||||
for o in outputs:
|
||||
_create_sub_output(db, obj.id, tenant_no, user_id, now, o)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _soft_delete_sub_records(db: Session, model_class, process_id: str, now: datetime) -> None:
|
||||
rows = (
|
||||
db.query(model_class)
|
||||
.filter(and_(model_class.process_id == process_id, model_class.del_flag == 0))
|
||||
.all()
|
||||
)
|
||||
for r in rows:
|
||||
r.del_flag = 1
|
||||
r.update_time = now
|
||||
db.add(r)
|
||||
|
||||
|
||||
def update_process(
|
||||
db: Session,
|
||||
process_id: str,
|
||||
*,
|
||||
process_code: Optional[str] = None,
|
||||
process_name: Optional[str] = None,
|
||||
category_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
equipment_name: Optional[str] = None,
|
||||
estimated_duration: Optional[str] = None,
|
||||
sop_ids: Optional[str] = None,
|
||||
reagents: Optional[List[Dict[str, Any]]] = None,
|
||||
consumables: Optional[List[Dict[str, Any]]] = None,
|
||||
outputs: Optional[List[Dict[str, Any]]] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizProcess:
|
||||
obj = get_process(db, process_id)
|
||||
if not obj:
|
||||
raise ValueError("工艺不存在")
|
||||
if process_code is not None:
|
||||
new_code = process_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("工艺编码不能为空")
|
||||
if new_code != obj.process_code and process_code_exists(db, new_code, obj.tenant_no, exclude_id=process_id):
|
||||
raise ValueError("工艺编码已存在")
|
||||
obj.process_code = new_code
|
||||
if process_name is not None:
|
||||
obj.process_name = (process_name or "").strip() or obj.process_name
|
||||
if category_id is not None:
|
||||
obj.category_id = (category_id or "").strip() or None
|
||||
if description is not None:
|
||||
obj.description = (description or "").strip() or None
|
||||
if equipment_name is not None:
|
||||
obj.equipment_name = (equipment_name or "").strip() or None
|
||||
if estimated_duration is not None:
|
||||
obj.estimated_duration = (estimated_duration or "").strip() or None
|
||||
if sop_ids is not None:
|
||||
obj.sop_ids = (sop_ids or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
|
||||
sub_updated = reagents is not None or consumables is not None or outputs is not None
|
||||
if sub_updated:
|
||||
now = datetime.now()
|
||||
if reagents is not None:
|
||||
_soft_delete_sub_records(db, BizProcessReagent, process_id, now)
|
||||
for r in reagents:
|
||||
_create_sub_reagent(db, process_id, obj.tenant_no, user_id, now, r)
|
||||
if consumables is not None:
|
||||
_soft_delete_sub_records(db, BizProcessConsumable, process_id, now)
|
||||
for c in consumables:
|
||||
_create_sub_consumable(db, process_id, obj.tenant_no, user_id, now, c)
|
||||
if outputs is not None:
|
||||
_soft_delete_sub_records(db, BizProcessOutput, process_id, now)
|
||||
for o in outputs:
|
||||
_create_sub_output(db, process_id, obj.tenant_no, user_id, now, o)
|
||||
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_process(db: Session, process_id: str) -> None:
|
||||
obj = get_process(db, process_id)
|
||||
if not obj:
|
||||
raise ValueError("工艺不存在")
|
||||
now = datetime.now()
|
||||
obj.del_flag = 1
|
||||
obj.update_time = now
|
||||
db.add(obj)
|
||||
|
||||
_soft_delete_sub_records(db, BizProcessReagent, process_id, now)
|
||||
_soft_delete_sub_records(db, BizProcessConsumable, process_id, now)
|
||||
_soft_delete_sub_records(db, BizProcessOutput, process_id, now)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -0,0 +1,163 @@
|
||||
"""工艺工序分类 CRUD(biz_process_category)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizProcessCategory
|
||||
|
||||
|
||||
def _row_to_dict(c: BizProcessCategory) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": c.id,
|
||||
"categoryCode": c.category_code,
|
||||
"categoryName": c.category_name,
|
||||
"tenantNo": c.tenant_no,
|
||||
"orderNum": c.order_num if c.order_num is not None else 0,
|
||||
"remark": c.remark or "",
|
||||
"createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
|
||||
"updateTime": c.update_time.strftime("%Y-%m-%d %H:%M:%S") if c.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_process_categories(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizProcessCategory).filter(BizProcessCategory.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizProcessCategory.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizProcessCategory.category_code.like(like),
|
||||
BizProcessCategory.category_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizProcessCategory.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizProcessCategory.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_process_category(db: Session, category_id: str) -> Optional[BizProcessCategory]:
|
||||
return (
|
||||
db.query(BizProcessCategory)
|
||||
.filter(and_(BizProcessCategory.id == category_id, BizProcessCategory.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def category_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizProcessCategory).filter(
|
||||
and_(BizProcessCategory.category_code == code.strip(), BizProcessCategory.tenant_no == tenant_no, BizProcessCategory.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizProcessCategory.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_process_category(
|
||||
db: Session,
|
||||
*,
|
||||
category_code: str,
|
||||
category_name: str,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizProcessCategory:
|
||||
cc = (category_code or "").strip()
|
||||
if not cc:
|
||||
raise ValueError("分类编码不能为空")
|
||||
if category_code_exists(db, cc, tenant_no):
|
||||
raise ValueError("分类编码已存在")
|
||||
obj = BizProcessCategory(
|
||||
id=generate_uuid(),
|
||||
category_code=cc,
|
||||
category_name=(category_name or "").strip() or cc,
|
||||
tenant_no=tenant_no,
|
||||
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_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_process_category(
|
||||
db: Session,
|
||||
category_id: str,
|
||||
*,
|
||||
category_code: Optional[str] = None,
|
||||
category_name: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizProcessCategory:
|
||||
obj = get_process_category(db, category_id)
|
||||
if not obj:
|
||||
raise ValueError("工艺工序分类不存在")
|
||||
if category_code is not None:
|
||||
new_code = category_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("分类编码不能为空")
|
||||
if new_code != obj.category_code and category_code_exists(db, new_code, obj.tenant_no, exclude_id=category_id):
|
||||
raise ValueError("分类编码已存在")
|
||||
obj.category_code = new_code
|
||||
if category_name is not None:
|
||||
obj.category_name = (category_name or "").strip() or obj.category_name
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_process_category(db: Session, category_id: str) -> None:
|
||||
obj = get_process_category(db, category_id)
|
||||
if not obj:
|
||||
raise ValueError("工艺工序分类不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
@@ -0,0 +1,187 @@
|
||||
"""试剂 CRUD(biz_reagent)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizReagent
|
||||
|
||||
|
||||
def _row_to_dict(r: BizReagent) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"reagentCode": r.reagent_code,
|
||||
"reagentName": r.reagent_name,
|
||||
"spec": r.spec or "",
|
||||
"standardUsage": r.standard_usage or "",
|
||||
"unit": r.unit or "",
|
||||
"description": r.description or "",
|
||||
"tenantNo": r.tenant_no,
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
"remark": r.remark or "",
|
||||
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
|
||||
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_reagents(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizReagent).filter(BizReagent.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizReagent.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizReagent.reagent_code.like(like),
|
||||
BizReagent.reagent_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizReagent.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizReagent.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_reagent(db: Session, reagent_id: str) -> Optional[BizReagent]:
|
||||
return (
|
||||
db.query(BizReagent)
|
||||
.filter(and_(BizReagent.id == reagent_id, BizReagent.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def reagent_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizReagent).filter(
|
||||
and_(BizReagent.reagent_code == code.strip(), BizReagent.tenant_no == tenant_no, BizReagent.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizReagent.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_reagent(
|
||||
db: Session,
|
||||
*,
|
||||
reagent_code: str,
|
||||
reagent_name: str,
|
||||
spec: Optional[str] = None,
|
||||
standard_usage: Optional[str] = None,
|
||||
unit: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizReagent:
|
||||
rc = (reagent_code or "").strip()
|
||||
if not rc:
|
||||
raise ValueError("试剂编码不能为空")
|
||||
if reagent_code_exists(db, rc, tenant_no):
|
||||
raise ValueError("试剂编码已存在")
|
||||
obj = BizReagent(
|
||||
id=generate_uuid(),
|
||||
reagent_code=rc,
|
||||
reagent_name=(reagent_name or "").strip() or rc,
|
||||
spec=(spec or "").strip() or None,
|
||||
standard_usage=(standard_usage or "").strip() or None,
|
||||
unit=(unit or "").strip() or None,
|
||||
description=(description or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
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_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_reagent(
|
||||
db: Session,
|
||||
reagent_id: str,
|
||||
*,
|
||||
reagent_code: Optional[str] = None,
|
||||
reagent_name: Optional[str] = None,
|
||||
spec: Optional[str] = None,
|
||||
standard_usage: Optional[str] = None,
|
||||
unit: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizReagent:
|
||||
obj = get_reagent(db, reagent_id)
|
||||
if not obj:
|
||||
raise ValueError("试剂不存在")
|
||||
if reagent_code is not None:
|
||||
new_code = reagent_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("试剂编码不能为空")
|
||||
if new_code != obj.reagent_code and reagent_code_exists(db, new_code, obj.tenant_no, exclude_id=reagent_id):
|
||||
raise ValueError("试剂编码已存在")
|
||||
obj.reagent_code = new_code
|
||||
if reagent_name is not None:
|
||||
obj.reagent_name = (reagent_name or "").strip() or obj.reagent_name
|
||||
if spec is not None:
|
||||
obj.spec = (spec or "").strip() or None
|
||||
if standard_usage is not None:
|
||||
obj.standard_usage = (standard_usage or "").strip() or None
|
||||
if unit is not None:
|
||||
obj.unit = (unit or "").strip() or None
|
||||
if description is not None:
|
||||
obj.description = (description or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_reagent(db: Session, reagent_id: str) -> None:
|
||||
obj = get_reagent(db, reagent_id)
|
||||
if not obj:
|
||||
raise ValueError("试剂不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,175 @@
|
||||
"""SOP文档 CRUD(biz_sop_document)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizSopDocument
|
||||
|
||||
|
||||
def _row_to_dict(s: BizSopDocument) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": s.id,
|
||||
"sopCode": s.sop_code,
|
||||
"sopName": s.sop_name,
|
||||
"versionNo": s.version_no or "",
|
||||
"sopAttachments": s.sop_attachments or "",
|
||||
"tenantNo": s.tenant_no,
|
||||
"orderNum": s.order_num if s.order_num is not None else 0,
|
||||
"remark": s.remark or "",
|
||||
"createTime": s.create_time.strftime("%Y-%m-%d %H:%M:%S") if s.create_time else "",
|
||||
"updateTime": s.update_time.strftime("%Y-%m-%d %H:%M:%S") if s.update_time else "",
|
||||
}
|
||||
|
||||
|
||||
def list_sop_documents(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizSopDocument).filter(BizSopDocument.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizSopDocument.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizSopDocument.sop_code.like(like),
|
||||
BizSopDocument.sop_name.like(like),
|
||||
)
|
||||
)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizSopDocument.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizSopDocument.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_sop_document(db: Session, sop_id: str) -> Optional[BizSopDocument]:
|
||||
return (
|
||||
db.query(BizSopDocument)
|
||||
.filter(and_(BizSopDocument.id == sop_id, BizSopDocument.del_flag == 0))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def sop_code_exists(db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
|
||||
q = db.query(BizSopDocument).filter(
|
||||
and_(BizSopDocument.sop_code == code.strip(), BizSopDocument.tenant_no == tenant_no, BizSopDocument.del_flag == 0)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizSopDocument.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def create_sop_document(
|
||||
db: Session,
|
||||
*,
|
||||
sop_code: str,
|
||||
sop_name: str,
|
||||
version_no: Optional[str] = None,
|
||||
sop_attachments: Optional[str] = None,
|
||||
tenant_no: str = "zkxw",
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizSopDocument:
|
||||
sc = (sop_code or "").strip()
|
||||
if not sc:
|
||||
raise ValueError("SOP编码不能为空")
|
||||
if sop_code_exists(db, sc, tenant_no):
|
||||
raise ValueError("SOP编码已存在")
|
||||
obj = BizSopDocument(
|
||||
id=generate_uuid(),
|
||||
sop_code=sc,
|
||||
sop_name=(sop_name or "").strip() or sc,
|
||||
version_no=(version_no or "").strip() or None,
|
||||
sop_attachments=(sop_attachments or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
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_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_sop_document(
|
||||
db: Session,
|
||||
sop_id: str,
|
||||
*,
|
||||
sop_code: Optional[str] = None,
|
||||
sop_name: Optional[str] = None,
|
||||
version_no: Optional[str] = None,
|
||||
sop_attachments: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> BizSopDocument:
|
||||
obj = get_sop_document(db, sop_id)
|
||||
if not obj:
|
||||
raise ValueError("SOP文档不存在")
|
||||
if sop_code is not None:
|
||||
new_code = sop_code.strip()
|
||||
if not new_code:
|
||||
raise ValueError("SOP编码不能为空")
|
||||
if new_code != obj.sop_code and sop_code_exists(db, new_code, obj.tenant_no, exclude_id=sop_id):
|
||||
raise ValueError("SOP编码已存在")
|
||||
obj.sop_code = new_code
|
||||
if sop_name is not None:
|
||||
obj.sop_name = (sop_name or "").strip() or obj.sop_name
|
||||
if version_no is not None:
|
||||
obj.version_no = (version_no or "").strip() or None
|
||||
if sop_attachments is not None:
|
||||
obj.sop_attachments = (sop_attachments or "").strip() or None
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = (remark or "").strip() or None
|
||||
obj.update_by = user_id
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def soft_delete_sop_document(db: Session, sop_id: str) -> None:
|
||||
obj = get_sop_document(db, sop_id)
|
||||
if not obj:
|
||||
raise ValueError("SOP文档不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return int(math.ceil(total / float(page_size)))
|
||||
+4
-1
@@ -10,7 +10,7 @@ from app.core.database import SessionLocal
|
||||
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
|
||||
from app.core.api_crypto_middleware import ApiCryptoMiddleware
|
||||
from app.models import models # noqa: F401
|
||||
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, qc_router
|
||||
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, qc_router, research_donation_router, production_router, pilot_service_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -69,6 +69,9 @@ app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(ethnic_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(sample_reception_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(qc_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(research_donation_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(production_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(pilot_service_router, prefix=settings.API_V1_PREFIX)
|
||||
|
||||
logger = logging.getLogger("sqxp")
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ from app.models.models import (
|
||||
SysApiRequestLog,
|
||||
SysApiRequestLogHis,
|
||||
UserRole,
|
||||
BizConsumable,
|
||||
BizReagent,
|
||||
BizSopDocument,
|
||||
BizProcessCategory,
|
||||
BizOutputType,
|
||||
BizConsumablePack,
|
||||
BizConsumablePackItem,
|
||||
BizProcess,
|
||||
BizProcessReagent,
|
||||
BizProcessConsumable,
|
||||
BizProcessOutput,
|
||||
)
|
||||
from app.models.research_donation import ( # noqa: F401
|
||||
BizResearchDonation,
|
||||
BizResearchDonationAudit,
|
||||
BizResearchDonationParty,
|
||||
BizResearchDonationCollection,
|
||||
BizResearchDonationSample,
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
|
||||
User = SysUser
|
||||
@@ -47,4 +66,15 @@ __all__ = [
|
||||
"UserRole",
|
||||
"User",
|
||||
"ApiRequestLog",
|
||||
"BizConsumable",
|
||||
"BizReagent",
|
||||
"BizSopDocument",
|
||||
"BizProcessCategory",
|
||||
"BizOutputType",
|
||||
"BizConsumablePack",
|
||||
"BizConsumablePackItem",
|
||||
"BizProcess",
|
||||
"BizProcessReagent",
|
||||
"BizProcessConsumable",
|
||||
"BizProcessOutput",
|
||||
]
|
||||
@@ -1353,4 +1353,592 @@ class BizExhibitionAppointment(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# V0.3 中试服务管理(2.3.1 ~ 2.3.6)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BizCellProductType(Base):
|
||||
"""细胞产品类型 biz_cell_product_type"""
|
||||
|
||||
__tablename__ = "biz_cell_product_type"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
product_type_code = Column(String(64), nullable=False, comment="细胞产品类型编码")
|
||||
product_type_name = Column(String(200), nullable=False, comment="细胞产品类型名称")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCellProductSpec(Base):
|
||||
"""细胞产品规格 biz_cell_product_spec"""
|
||||
|
||||
__tablename__ = "biz_cell_product_spec"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
spec_code = Column(String(64), nullable=False, comment="规格编码")
|
||||
spec_name = Column(String(200), nullable=False, comment="规格名称")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCellProductPrice(Base):
|
||||
"""细胞产品价格体系 biz_cell_product_price"""
|
||||
|
||||
__tablename__ = "biz_cell_product_price"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
project_name = Column(String(200), nullable=False, comment="项目名称")
|
||||
project_code = Column(String(64), nullable=False, comment="项目编码")
|
||||
cell_type_code = Column(String(64), nullable=True, comment="细胞类型编码")
|
||||
cell_type_name = Column(String(200), nullable=True, comment="细胞类型名称")
|
||||
spec_text = Column(String(200), nullable=True, comment="规格(展示或关联规格编码)")
|
||||
spec_id = Column(String(64), nullable=True, comment="规格ID")
|
||||
cell_product_type_id = Column(String(64), nullable=True, comment="细胞产品类型ID")
|
||||
unit_price = Column(Numeric(18, 2), nullable=True, comment="单价(元)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCellProductProjectSystem(Base):
|
||||
"""细胞产品项目价格体系 biz_cell_product_project_system"""
|
||||
|
||||
__tablename__ = "biz_cell_product_project_system"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
system_name = Column(String(200), nullable=False, comment="体系名称")
|
||||
system_code = Column(String(64), nullable=False, comment="体系编码")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1正常 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCellProductProjectSystemItem(Base):
|
||||
"""项目价格体系-细胞价格明细 biz_cell_product_project_system_item"""
|
||||
|
||||
__tablename__ = "biz_cell_product_project_system_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
project_system_id = Column(String(64), nullable=False, comment="项目价格体系ID")
|
||||
source_price_id = Column(String(64), nullable=True, comment="来源细胞产品价格体系行ID")
|
||||
project_name = Column(String(200), nullable=True, comment="项目名称")
|
||||
project_code = Column(String(64), nullable=True, comment="项目编码")
|
||||
cell_type_code = Column(String(64), nullable=True, comment="细胞类型编码")
|
||||
cell_type_name = Column(String(200), nullable=True, comment="细胞类型名称")
|
||||
spec_text = Column(String(200), nullable=True, comment="规格")
|
||||
spec_id = Column(String(64), nullable=True, comment="规格ID")
|
||||
cell_product_type_id = Column(String(64), nullable=True, comment="细胞产品类型ID")
|
||||
unit_price = Column(Numeric(18, 2), nullable=True, comment="单价(元)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizChannel(Base):
|
||||
"""渠道信息 biz_channel"""
|
||||
|
||||
__tablename__ = "biz_channel"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
channel_name = Column(String(200), nullable=False, comment="渠道名称")
|
||||
credit_code = Column(String(64), nullable=True, comment="统一社会信用代码")
|
||||
channel_type = Column(String(16), nullable=True, comment="yy医院 hzjg合作机构 zs诊所 zfjg政府机构 qt其他")
|
||||
contact_name = Column(String(100), nullable=True, comment="联系人姓名")
|
||||
contact_phone = Column(String(32), nullable=True, comment="联系电话")
|
||||
contact_email = Column(String(128), nullable=True, comment="联系邮箱")
|
||||
protocol_attachments = Column(String(4000), nullable=True, comment="渠道协议附件逗号分隔")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizChannelAgreement(Base):
|
||||
"""渠道协议 biz_channel_agreement"""
|
||||
|
||||
__tablename__ = "biz_channel_agreement"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
channel_id = Column(String(64), nullable=False, comment="渠道ID")
|
||||
agreement_type = Column(String(16), nullable=False, comment="kyxy科研 zsxy中试 lcxy临床")
|
||||
business_type = Column(String(32), nullable=True, comment="kyyy科研应用/中试应用/临床应用(随协议类型)")
|
||||
settle_mode = Column(String(16), nullable=True, comment="yj月结 jj季结 nj年结 dcjs单次结算")
|
||||
credit_limit_amount = Column(Numeric(18, 2), nullable=True, comment="授信额度(元)")
|
||||
effective_date = Column(CHAR(8), nullable=True, comment="生效日期YYYYMMDD")
|
||||
expire_date = Column(CHAR(8), nullable=True, comment="到期日期YYYYMMDD")
|
||||
agreement_status = Column(String(16), nullable=False, default="dsx", comment="dsx待生效 sxz生效中 yzf已作废")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizChannelAgreementProductPrice(Base):
|
||||
"""渠道协议-细胞产品价格 biz_channel_agreement_product_price"""
|
||||
|
||||
__tablename__ = "biz_channel_agreement_product_price"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
channel_agreement_id = Column(String(64), nullable=False, comment="渠道协议ID")
|
||||
project_name = Column(String(200), nullable=True, comment="项目名称")
|
||||
project_code = Column(String(64), nullable=True, comment="项目编码")
|
||||
cell_type_code = Column(String(64), nullable=True, comment="细胞类型编码")
|
||||
cell_type_name = Column(String(200), nullable=True, comment="细胞类型名称")
|
||||
spec_text = Column(String(200), nullable=True, comment="规格")
|
||||
original_price = Column(Numeric(18, 2), nullable=True, comment="原价(元)")
|
||||
agreement_price = Column(Numeric(18, 2), nullable=True, comment="协议价(元)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizChannelInvoice(Base):
|
||||
"""渠道开票信息 biz_channel_invoice"""
|
||||
|
||||
__tablename__ = "biz_channel_invoice"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
channel_id = Column(String(64), nullable=False, comment="渠道ID")
|
||||
company_name = Column(String(200), nullable=False, comment="单位名称")
|
||||
tax_no = Column(String(64), nullable=True, comment="税号")
|
||||
company_address = Column(String(512), nullable=True, comment="单位地址")
|
||||
company_phone = Column(String(64), nullable=True, comment="电话")
|
||||
bank_name = Column(String(200), nullable=True, comment="开户银行")
|
||||
bank_account = Column(String(64), nullable=True, comment="银行账号")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizChannelMailAddress(Base):
|
||||
"""渠道邮寄地址 biz_channel_mail_address"""
|
||||
|
||||
__tablename__ = "biz_channel_mail_address"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
channel_id = Column(String(64), nullable=False, comment="渠道ID")
|
||||
mail_address = Column(String(512), nullable=False, comment="邮寄地址")
|
||||
mail_contact = Column(String(100), nullable=True, comment="邮寄联系人")
|
||||
mail_phone = Column(String(32), nullable=True, comment="邮寄联系电话")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCheckupInstitution(Base):
|
||||
"""体检机构 biz_checkup_institution"""
|
||||
|
||||
__tablename__ = "biz_checkup_institution"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
institution_name = Column(String(200), nullable=False, comment="体检机构名称")
|
||||
institution_address = Column(String(512), nullable=True, comment="体检机构地址")
|
||||
contact_name = Column(String(100), nullable=True, comment="联系人")
|
||||
contact_phone = Column(String(32), nullable=True, comment="联系人电话")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1正常 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCheckupInstitutionInvoice(Base):
|
||||
"""体检机构开票信息 biz_checkup_institution_invoice"""
|
||||
|
||||
__tablename__ = "biz_checkup_institution_invoice"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
checkup_institution_id = Column(String(64), nullable=False, comment="体检机构ID")
|
||||
company_name = Column(String(200), nullable=False, comment="单位名称")
|
||||
tax_no = Column(String(64), nullable=True, comment="税号")
|
||||
company_address = Column(String(512), nullable=True, comment="单位地址")
|
||||
company_phone = Column(String(64), nullable=True, comment="电话")
|
||||
bank_name = Column(String(200), nullable=True, comment="开户银行")
|
||||
bank_account = Column(String(64), nullable=True, comment="银行账号")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCheckupInstitutionProject(Base):
|
||||
"""体检机构项目及价格 biz_checkup_institution_project"""
|
||||
|
||||
__tablename__ = "biz_checkup_institution_project"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
checkup_institution_id = Column(String(64), nullable=False, comment="体检机构ID")
|
||||
project_name = Column(String(200), nullable=False, comment="项目名称")
|
||||
project_fee = Column(Numeric(18, 2), nullable=True, comment="项目费用(元)")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1正常 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCheckupInstitutionPackage(Base):
|
||||
"""体检机构项目套餐 biz_checkup_institution_package"""
|
||||
|
||||
__tablename__ = "biz_checkup_institution_package"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
checkup_institution_id = Column(String(64), nullable=False, comment="体检机构ID")
|
||||
package_name = Column(String(200), nullable=False, comment="套餐名称")
|
||||
package_code = Column(String(64), nullable=False, comment="套餐编码")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1正常 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCheckupInstitutionPackageItem(Base):
|
||||
"""体检机构项目套餐明细 biz_checkup_institution_package_item"""
|
||||
|
||||
__tablename__ = "biz_checkup_institution_package_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
package_id = Column(String(64), nullable=False, comment="套餐ID")
|
||||
checkup_institution_project_id = Column(String(64), nullable=False, comment="机构项目ID")
|
||||
project_name = Column(String(200), nullable=False, comment="项目名称快照")
|
||||
project_fee = Column(Numeric(18, 2), nullable=True, comment="套餐内项目费用(元)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 生产管理(耗材 / 试剂 / SOP / 工序分类 / 产出类型 / 耗材包 / 工艺库)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class BizConsumable(Base):
|
||||
"""生产耗材 biz_consumable"""
|
||||
|
||||
__tablename__ = "biz_consumable"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
consumable_code = Column(String(64), nullable=False, comment="耗材编码")
|
||||
consumable_name = Column(String(100), nullable=False, comment="耗材名称")
|
||||
spec_model = Column(String(200), nullable=True, comment="规格型号")
|
||||
standard_usage = Column(String(100), nullable=True, comment="标准用量")
|
||||
description = Column(String(500), nullable=True, comment="描述")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizReagent(Base):
|
||||
"""试剂 biz_reagent"""
|
||||
|
||||
__tablename__ = "biz_reagent"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
reagent_code = Column(String(64), nullable=False, comment="试剂编码")
|
||||
reagent_name = Column(String(100), nullable=False, comment="试剂名称")
|
||||
spec = Column(String(200), nullable=True, comment="规格")
|
||||
standard_usage = Column(String(100), nullable=True, comment="标准用量")
|
||||
unit = Column(String(64), nullable=True, comment="单位")
|
||||
description = Column(String(500), nullable=True, comment="描述")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizSopDocument(Base):
|
||||
"""SOP文档 biz_sop_document"""
|
||||
|
||||
__tablename__ = "biz_sop_document"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
sop_code = Column(String(64), nullable=False, comment="SOP编号")
|
||||
sop_name = Column(String(200), nullable=False, comment="SOP文档名称")
|
||||
version_no = Column(String(50), nullable=True, comment="版本号")
|
||||
sop_attachments = Column(String(2000), nullable=True, comment="SOP文档附件(逗号分隔URL)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProcessCategory(Base):
|
||||
"""工序分类 biz_process_category"""
|
||||
|
||||
__tablename__ = "biz_process_category"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
category_code = Column(String(64), nullable=False, comment="分类编码")
|
||||
category_name = Column(String(100), nullable=False, comment="分类名称")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizOutputType(Base):
|
||||
"""产出类型 biz_output_type"""
|
||||
|
||||
__tablename__ = "biz_output_type"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
type_code = Column(String(64), nullable=False, comment="类型编码")
|
||||
type_name = Column(String(100), nullable=False, comment="类型名称")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizConsumablePack(Base):
|
||||
"""耗材包 biz_consumable_pack"""
|
||||
|
||||
__tablename__ = "biz_consumable_pack"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
pack_code = Column(String(64), nullable=False, comment="编号")
|
||||
pack_name = Column(String(100), nullable=False, comment="名称")
|
||||
usage_scene = Column(String(200), nullable=True, comment="使用场景")
|
||||
pack_type = Column(String(20), nullable=True, comment="类型(bzhcb-标准耗材包 zdyhcb-自定义耗材包)")
|
||||
status = Column(CHAR(1), nullable=True, default="1", comment="状态(1-启用 0-停用)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizConsumablePackItem(Base):
|
||||
"""耗材包明细 biz_consumable_pack_item"""
|
||||
|
||||
__tablename__ = "biz_consumable_pack_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
pack_id = Column(String(64), nullable=False, comment="耗材包id")
|
||||
consumable_code = Column(String(64), nullable=True, comment="耗材编码")
|
||||
consumable_name = Column(String(100), nullable=True, comment="耗材名称")
|
||||
spec_model = Column(String(200), nullable=True, comment="耗材规格型号")
|
||||
standard_usage = Column(String(100), nullable=True, comment="标准用量")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProcess(Base):
|
||||
"""工艺 biz_process"""
|
||||
|
||||
__tablename__ = "biz_process"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_code = Column(String(64), nullable=False, comment="工序代码")
|
||||
category_id = Column(String(64), nullable=True, comment="工序分类id")
|
||||
process_name = Column(String(100), nullable=False, comment="工序名称")
|
||||
description = Column(String(500), nullable=True, comment="描述")
|
||||
equipment_name = Column(String(200), nullable=True, comment="预计使用设备名称")
|
||||
estimated_duration = Column(String(100), nullable=True, comment="预估耗时")
|
||||
sop_ids = Column(String(1000), nullable=True, comment="关联SOP文档id(逗号分隔)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProcessReagent(Base):
|
||||
"""工艺试剂 biz_process_reagent"""
|
||||
|
||||
__tablename__ = "biz_process_reagent"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_id = Column(String(64), nullable=False, comment="工艺id")
|
||||
reagent_code = Column(String(64), nullable=True, comment="试剂编码")
|
||||
reagent_name = Column(String(100), nullable=True, comment="试剂名称")
|
||||
spec = Column(String(200), nullable=True, comment="规格")
|
||||
standard_usage = Column(String(100), nullable=True, comment="标准用量")
|
||||
unit = Column(String(64), nullable=True, comment="单位")
|
||||
is_key_material = Column(CHAR(1), nullable=True, default="0", comment="是否关键物料(0-否 1-是)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProcessConsumable(Base):
|
||||
"""工艺耗材 biz_process_consumable"""
|
||||
|
||||
__tablename__ = "biz_process_consumable"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_id = Column(String(64), nullable=False, comment="工艺id")
|
||||
consumable_code = Column(String(64), nullable=True, comment="耗材编码")
|
||||
consumable_name = Column(String(100), nullable=True, comment="耗材名称")
|
||||
spec_model = Column(String(200), nullable=True, comment="耗材规格型号")
|
||||
standard_usage = Column(String(100), nullable=True, comment="标准用量")
|
||||
is_required = Column(CHAR(1), nullable=True, default="1", comment="是否必需(1-必须 0-可选)")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProcessOutput(Base):
|
||||
"""工艺产出物 biz_process_output"""
|
||||
|
||||
__tablename__ = "biz_process_output"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
process_id = Column(String(64), nullable=False, comment="工艺id")
|
||||
output_type_id = Column(String(64), nullable=True, comment="产出类型id")
|
||||
output_name = Column(String(200), nullable=True, comment="产出物名称")
|
||||
output_barcode = Column(String(200), nullable=True, comment="产出物条码")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
from app.models.qc_param import BizQcItemParam, BizQcTaskResult # noqa: E402,F401
|
||||
@@ -0,0 +1,178 @@
|
||||
"""科研捐赠管理 — 数据模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, Numeric, SmallInteger, String
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class BizResearchDonation(Base):
|
||||
"""科研捐赠主表 biz_research_donation"""
|
||||
|
||||
__tablename__ = "biz_research_donation"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
donation_no = Column(String(64), nullable=False, comment="捐赠协议编号")
|
||||
donation_status = Column(String(16), nullable=False, default="dsx", comment="状态 dsx/sxz/ygq/yzf/zckc")
|
||||
donation_date = Column(String(8), nullable=True, comment="捐赠日期YYYYMMDD")
|
||||
source_type = Column(String(20), nullable=False, default="direct", comment="来源类型 direct/from_storage")
|
||||
source_protocol_id = Column(String(64), nullable=True, comment="来源协议ID")
|
||||
consent_form_urls = Column(String(4000), nullable=True, comment="知情同意书附件URL(逗号分隔)")
|
||||
attachment_urls = Column(String(4000), nullable=True, comment="其他附件URL(逗号分隔)")
|
||||
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
|
||||
effective_time = Column(DateTime, nullable=True, comment="生效时间")
|
||||
effective_desc = Column(String(512), nullable=True, comment="生效描述")
|
||||
expire_time = Column(DateTime, nullable=True, comment="过期时间")
|
||||
expire_desc = Column(String(512), nullable=True, comment="过期描述")
|
||||
void_time = Column(DateTime, nullable=True, comment="作废时间")
|
||||
void_desc = Column(String(512), nullable=True, comment="作废描述")
|
||||
to_storage_time = Column(DateTime, nullable=True, comment="转存储时间")
|
||||
to_storage_desc = Column(String(512), nullable=True, comment="转存储描述")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注信息")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationAudit(Base):
|
||||
"""科研捐赠审核记录 biz_research_donation_audit"""
|
||||
|
||||
__tablename__ = "biz_research_donation_audit"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
auditor_user_id = Column(String(64), nullable=True, comment="审核人")
|
||||
audit_remark = Column(String(1000), nullable=True, comment="审核备注")
|
||||
audit_status = Column(String(16), nullable=False, comment="审核状态 shtg/shth")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationParty(Base):
|
||||
"""科研捐赠当事人信息 biz_research_donation_party"""
|
||||
|
||||
__tablename__ = "biz_research_donation_party"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
customer_id = Column(String(64), nullable=True, comment="客户ID")
|
||||
mother_name = Column(String(100), nullable=True, comment="母亲姓名")
|
||||
mother_phone = Column(String(32), nullable=True, comment="母亲电话")
|
||||
mother_id_card = Column(String(32), nullable=True, comment="母亲身份证号")
|
||||
mother_birth_ym = Column(String(6), nullable=True, comment="母亲出生年月YYYYMM")
|
||||
mother_ethnic = Column(String(32), nullable=True, comment="母亲民族")
|
||||
mother_address = Column(String(512), nullable=True, comment="母亲地址")
|
||||
father_name = Column(String(100), nullable=True, comment="父亲姓名")
|
||||
father_phone = Column(String(32), nullable=True, comment="父亲电话")
|
||||
father_id_card = Column(String(32), nullable=True, comment="父亲身份证号")
|
||||
newborn_name = Column(String(100), nullable=True, comment="新生儿姓名")
|
||||
newborn_id_card = Column(String(32), nullable=True, comment="新生儿身份证号")
|
||||
newborn_delivery_time = Column(DateTime, nullable=True, comment="新生儿分娩时间")
|
||||
newborn_sex = Column(String(1), nullable=True, comment="新生儿性别")
|
||||
delivery_mode = Column(String(16), nullable=True, comment="分娩方式")
|
||||
birth_order = Column(Integer, nullable=True, comment="出生顺序")
|
||||
newborn_weight_gram = Column(Integer, nullable=True, comment="新生儿体重(克)")
|
||||
apgar_1min = Column(Numeric(4, 1), nullable=True, comment="Apgar 1分钟评分")
|
||||
apgar_5min = Column(Numeric(4, 1), nullable=True, comment="Apgar 5分钟评分")
|
||||
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationCollection(Base):
|
||||
"""科研捐赠采集信息 biz_research_donation_collection"""
|
||||
|
||||
__tablename__ = "biz_research_donation_collection"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
region_id = Column(String(64), nullable=True, comment="区域ID")
|
||||
hospital_id = Column(String(64), nullable=True, comment="医院ID")
|
||||
medical_staff = Column(String(200), nullable=True, comment="医护人员")
|
||||
sales_user_id = Column(String(64), nullable=True, comment="销售人员用户ID")
|
||||
sample_collect_time = Column(DateTime, nullable=True, comment="样本采集时间")
|
||||
maternal_blood_collect_time = Column(DateTime, nullable=True, comment="母血采集时间")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationSample(Base):
|
||||
"""科研捐赠样本信息 biz_research_donation_sample(无金额字段)"""
|
||||
|
||||
__tablename__ = "biz_research_donation_sample"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
sample_type_id = Column(String(64), nullable=True, comment="样本类型ID")
|
||||
sample_code_id = Column(String(64), nullable=True, comment="样本编码ID")
|
||||
sample_code = Column(String(32), nullable=True, comment="样本编码")
|
||||
collect_amount = Column(Numeric(18, 4), nullable=True, comment="采集量")
|
||||
unit_id = Column(String(64), nullable=True, comment="单位ID")
|
||||
storage_years = Column(Integer, nullable=True, comment="存储年限")
|
||||
sample_status = Column(String(16), nullable=True, comment="样本状态")
|
||||
storage_in_date = Column(String(8), nullable=True, comment="入库日期YYYYMMDD")
|
||||
detect_start_date = Column(String(8), nullable=True, comment="检测开始日期YYYYMMDD")
|
||||
void_date = Column(String(8), nullable=True, comment="作废日期YYYYMMDD")
|
||||
storage_expire_date = Column(String(8), nullable=True, comment="存储到期日期YYYYMMDD")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationSampleTransport(Base):
|
||||
"""科研捐赠样本运输 biz_research_donation_sample_transport"""
|
||||
|
||||
__tablename__ = "biz_research_donation_sample_transport"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
sample_id = Column(String(64), nullable=False, comment="样本ID")
|
||||
transport_type = Column(String(16), nullable=False, comment="kdys快递运输 ryxd人员携带")
|
||||
express_company_id = Column(String(64), nullable=True, comment="快递公司ID")
|
||||
waybill_no = Column(String(128), nullable=True, comment="快递单号")
|
||||
dest_site_id = Column(String(64), nullable=True, comment="目的站点ID")
|
||||
express_fee = Column(Numeric(18, 2), nullable=True, comment="快递费用(元)")
|
||||
eta_time = Column(DateTime, nullable=True, comment="预计到达时间")
|
||||
carrier_name = Column(String(100), nullable=True, comment="携带人员姓名")
|
||||
carrier_phone = Column(String(32), nullable=True, comment="携带人员联系电话")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 体检机构项目套餐(主表 + 明细表)
|
||||
-- 套餐总价不落库,由明细 project_fee 汇总计算
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for biz_checkup_institution_package
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `biz_checkup_institution_package_item`;
|
||||
DROP TABLE IF EXISTS `biz_checkup_institution_package`;
|
||||
|
||||
CREATE TABLE `biz_checkup_institution_package` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`checkup_institution_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '体检机构ID',
|
||||
`package_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '套餐名称',
|
||||
`package_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '套餐编码(必填,机构内未删除记录唯一,应用层校验)',
|
||||
`status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '1' COMMENT '1正常 0停用',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ck_pkg_inst` (`checkup_institution_id`,`del_flag`),
|
||||
KEY `idx_ck_pkg_code` (`checkup_institution_id`,`package_code`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体检机构项目套餐';
|
||||
|
||||
CREATE TABLE `biz_checkup_institution_package_item` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`package_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '套餐ID',
|
||||
`checkup_institution_project_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '机构项目ID',
|
||||
`project_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目名称(快照)',
|
||||
`project_fee` decimal(18,2) DEFAULT NULL COMMENT '套餐内项目费用(元),可独立于机构项目价',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ck_pkg_item` (`package_id`,`checkup_institution_project_id`),
|
||||
KEY `idx_ck_pkg_item_pkg` (`package_id`,`del_flag`),
|
||||
KEY `idx_ck_pkg_item_proj` (`checkup_institution_project_id`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体检机构项目套餐明细';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 体检机构项目及价格表:增加状态字段
|
||||
ALTER TABLE `biz_checkup_institution_project`
|
||||
ADD COLUMN `status` char(1) NOT NULL DEFAULT '1' COMMENT '1正常 0停用' AFTER `project_fee`;
|
||||
@@ -0,0 +1,294 @@
|
||||
-- ============================================================================
|
||||
-- 生产管理模块 DDL(11张业务表 + 菜单 INSERT)
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1. 生产耗材 biz_consumable
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_consumable` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`consumable_code` varchar(64) NOT NULL COMMENT '耗材编码',
|
||||
`consumable_name` varchar(100) NOT NULL COMMENT '耗材名称',
|
||||
`spec_model` varchar(200) DEFAULT NULL COMMENT '规格型号',
|
||||
`standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '描述',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_consumable_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生产耗材';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 2. 试剂 biz_reagent
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_reagent` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`reagent_code` varchar(64) NOT NULL COMMENT '试剂编码',
|
||||
`reagent_name` varchar(100) NOT NULL COMMENT '试剂名称',
|
||||
`spec` varchar(200) DEFAULT NULL COMMENT '规格',
|
||||
`standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量',
|
||||
`unit` varchar(64) DEFAULT NULL COMMENT '单位',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '描述',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_reagent_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试剂';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 3. SOP文档 biz_sop_document
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_sop_document` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`sop_code` varchar(64) NOT NULL COMMENT 'SOP编号',
|
||||
`sop_name` varchar(200) NOT NULL COMMENT 'SOP文档名称',
|
||||
`version_no` varchar(50) DEFAULT NULL COMMENT '版本号',
|
||||
`sop_attachments` varchar(2000) DEFAULT NULL COMMENT 'SOP文档附件(逗号分隔URL)',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sop_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SOP文档';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 4. 工序分类 biz_process_category
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_process_category` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`category_code` varchar(64) NOT NULL COMMENT '分类编码',
|
||||
`category_name` varchar(100) NOT NULL COMMENT '分类名称',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pcat_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工序分类';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 5. 产出类型 biz_output_type
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_output_type` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`type_code` varchar(64) NOT NULL COMMENT '类型编码',
|
||||
`type_name` varchar(100) NOT NULL COMMENT '类型名称',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_otype_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产出类型';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 6. 耗材包 biz_consumable_pack
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_consumable_pack` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`pack_code` varchar(64) NOT NULL COMMENT '编号',
|
||||
`pack_name` varchar(100) NOT NULL COMMENT '名称',
|
||||
`usage_scene` varchar(200) DEFAULT NULL COMMENT '使用场景',
|
||||
`pack_type` varchar(20) DEFAULT NULL COMMENT '类型(bzhcb-标准耗材包 zdyhcb-自定义耗材包)',
|
||||
`status` char(1) DEFAULT '1' COMMENT '状态(1-启用 0-停用)',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_cpack_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='耗材包';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 7. 耗材包明细 biz_consumable_pack_item
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_consumable_pack_item` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`pack_id` varchar(64) NOT NULL COMMENT '耗材包id',
|
||||
`consumable_code` varchar(64) DEFAULT NULL COMMENT '耗材编码',
|
||||
`consumable_name` varchar(100) DEFAULT NULL COMMENT '耗材名称',
|
||||
`spec_model` varchar(200) DEFAULT NULL COMMENT '耗材规格型号',
|
||||
`standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_cpi_pack` (`pack_id`, `del_flag`),
|
||||
KEY `idx_cpi_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='耗材包明细';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 8. 工艺 biz_process
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_process` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`process_code` varchar(64) NOT NULL COMMENT '工序代码',
|
||||
`category_id` varchar(64) DEFAULT NULL COMMENT '工序分类id',
|
||||
`process_name` varchar(100) NOT NULL COMMENT '工序名称',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '描述',
|
||||
`equipment_name` varchar(200) DEFAULT NULL COMMENT '预计使用设备名称',
|
||||
`estimated_duration` varchar(100) DEFAULT NULL COMMENT '预估耗时',
|
||||
`sop_ids` varchar(1000) DEFAULT NULL COMMENT '关联SOP文档id(逗号分隔)',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_process_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 9. 工艺试剂 biz_process_reagent
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_process_reagent` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`process_id` varchar(64) NOT NULL COMMENT '工艺id',
|
||||
`reagent_code` varchar(64) DEFAULT NULL COMMENT '试剂编码',
|
||||
`reagent_name` varchar(100) DEFAULT NULL COMMENT '试剂名称',
|
||||
`spec` varchar(200) DEFAULT NULL COMMENT '规格',
|
||||
`standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量',
|
||||
`unit` varchar(64) DEFAULT NULL COMMENT '单位',
|
||||
`is_key_material` char(1) DEFAULT '0' COMMENT '是否关键物料(0-否 1-是)',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pr_process` (`process_id`, `del_flag`),
|
||||
KEY `idx_pr_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺试剂';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 10. 工艺耗材 biz_process_consumable
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_process_consumable` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`process_id` varchar(64) NOT NULL COMMENT '工艺id',
|
||||
`consumable_code` varchar(64) DEFAULT NULL COMMENT '耗材编码',
|
||||
`consumable_name` varchar(100) DEFAULT NULL COMMENT '耗材名称',
|
||||
`spec_model` varchar(200) DEFAULT NULL COMMENT '耗材规格型号',
|
||||
`standard_usage` varchar(100) DEFAULT NULL COMMENT '标准用量',
|
||||
`is_required` char(1) DEFAULT '1' COMMENT '是否必需(1-必须 0-可选)',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_pc_process` (`process_id`, `del_flag`),
|
||||
KEY `idx_pc_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺耗材';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 11. 工艺产出物 biz_process_output
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `biz_process_output` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`process_id` varchar(64) NOT NULL COMMENT '工艺id',
|
||||
`output_type_id` varchar(64) DEFAULT NULL COMMENT '产出类型id',
|
||||
`output_name` varchar(200) DEFAULT NULL COMMENT '产出物名称',
|
||||
`output_barcode` varchar(200) DEFAULT NULL COMMENT '产出物条码',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_po_process` (`process_id`, `del_flag`),
|
||||
KEY `idx_po_tenant` (`tenant_no`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工艺产出物';
|
||||
|
||||
-- ============================================================================
|
||||
-- 菜单数据 — 生产管理(父级 id='2' Web端菜单)
|
||||
-- ============================================================================
|
||||
|
||||
-- 生产管理 目录(parent_id=2 即 Web端菜单根)
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000001', '生产管理', '2', 'M', '/production', '/index/index', NULL, 1, '1', '1', 'zkxw', 7, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
|
||||
-- 耗材管理
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000002', '耗材管理', '90000000000000000000000000000001', 'C', 'consumable', '/production/consumable/index', 'production:consumable:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
|
||||
-- 试剂管理
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000003', '试剂管理', '90000000000000000000000000000001', 'C', 'reagent', '/production/reagent/index', 'production:reagent:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
|
||||
-- SOP文档
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000004', 'SOP文档', '90000000000000000000000000000001', 'C', 'sop', '/production/sop/index', 'production:sop:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
|
||||
-- 工艺工序分类
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000005', '工艺工序分类', '90000000000000000000000000000001', 'C', 'process-category', '/production/process-category/index', 'production:process-category:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
|
||||
-- 工艺产出物类型
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000006', '工艺产出物类型', '90000000000000000000000000000001', 'C', 'output-type', '/production/output-type/index', 'production:output-type:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
|
||||
-- 耗材包管理
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000007', '耗材包管理', '90000000000000000000000000000001', 'C', 'consumable-pack', '/production/consumable-pack/index', 'production:consumable-pack:list', 1, '1', '1', 'zkxw', 6, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
|
||||
-- 工艺库管理
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES
|
||||
('90000000000000000000000000000008', '工艺库管理', '90000000000000000000000000000001', 'C', 'process', '/production/process/index', 'production:process:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', '2026-05-18 00:00:00', NULL, '2026-05-18 00:00:00', 0);
|
||||
@@ -0,0 +1,176 @@
|
||||
-- 科研捐赠单主表
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`donation_no` varchar(64) NOT NULL COMMENT '捐赠协议编号',
|
||||
`donation_status` varchar(16) NOT NULL DEFAULT 'dsx' COMMENT '捐赠状态 dsx待上线 sxz上线中 ygq已过期 yzf已作废 zckc转出库存',
|
||||
`donation_date` char(8) DEFAULT NULL COMMENT '捐赠日期YYYYMMDD',
|
||||
`source_type` varchar(20) NOT NULL DEFAULT 'direct' COMMENT '来源类型 direct独立新建 from_storage从细胞存储转来',
|
||||
`source_protocol_id` varchar(64) DEFAULT NULL COMMENT '来源采集协议单ID(from_storage时关联)',
|
||||
`consent_form_urls` text DEFAULT NULL COMMENT '知情同意书附件URL(逗号分隔)',
|
||||
`attachment_urls` text DEFAULT NULL COMMENT '附件URL(逗号分隔)',
|
||||
`collector_user_id` varchar(64) DEFAULT NULL COMMENT '采集专员用户ID',
|
||||
`effective_time` datetime DEFAULT NULL COMMENT '生效时间',
|
||||
`effective_desc` varchar(512) DEFAULT NULL COMMENT '生效描述',
|
||||
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
|
||||
`expire_desc` varchar(512) DEFAULT NULL COMMENT '过期描述',
|
||||
`void_time` datetime DEFAULT NULL COMMENT '作废时间',
|
||||
`void_desc` varchar(512) DEFAULT NULL COMMENT '作废描述',
|
||||
`to_storage_time` datetime DEFAULT NULL COMMENT '转库存时间',
|
||||
`to_storage_desc` varchar(512) DEFAULT NULL COMMENT '转库存描述',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注信息',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rd_tenant_status` (`tenant_no`, `donation_status`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠单主表';
|
||||
|
||||
-- 科研捐赠单-审核记录
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_audit` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠单ID',
|
||||
`auditor_user_id` varchar(64) DEFAULT NULL COMMENT '审核人',
|
||||
`audit_remark` varchar(1000) DEFAULT NULL COMMENT '审核备注',
|
||||
`audit_status` varchar(16) NOT NULL COMMENT '审核状态 shtg审核通过/shth审核退回',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rda_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠单-审核记录';
|
||||
|
||||
-- 科研捐赠单-当事人客户信息
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_party` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠单ID',
|
||||
`customer_id` varchar(64) DEFAULT NULL COMMENT '客户ID',
|
||||
`mother_name` varchar(100) DEFAULT NULL COMMENT '母亲姓名',
|
||||
`mother_phone` varchar(32) DEFAULT NULL COMMENT '母亲电话',
|
||||
`mother_id_card` varchar(32) DEFAULT NULL COMMENT '母亲身份证号',
|
||||
`mother_birth_ym` char(6) DEFAULT NULL COMMENT '母亲出生年月YYYYMM',
|
||||
`mother_ethnic` varchar(32) DEFAULT NULL COMMENT '母亲民族',
|
||||
`mother_address` varchar(512) DEFAULT NULL COMMENT '母亲地址',
|
||||
`father_name` varchar(100) DEFAULT NULL COMMENT '父亲姓名',
|
||||
`father_phone` varchar(32) DEFAULT NULL COMMENT '父亲电话',
|
||||
`father_id_card` varchar(32) DEFAULT NULL COMMENT '父亲身份证号',
|
||||
`newborn_name` varchar(100) DEFAULT NULL COMMENT '新生儿姓名',
|
||||
`newborn_id_card` varchar(32) DEFAULT NULL COMMENT '新生儿身份证号',
|
||||
`newborn_delivery_time` datetime DEFAULT NULL COMMENT '新生儿分娩时间',
|
||||
`newborn_sex` char(1) DEFAULT NULL COMMENT '新生儿性别',
|
||||
`delivery_mode` varchar(16) DEFAULT NULL COMMENT '分娩方式',
|
||||
`birth_order` int(11) DEFAULT NULL COMMENT '出生顺序',
|
||||
`newborn_weight_gram` int(11) DEFAULT NULL COMMENT '新生儿体重(克)',
|
||||
`apgar_1min` decimal(4,1) DEFAULT NULL COMMENT 'Apgar 1分钟评分',
|
||||
`apgar_5min` decimal(4,1) DEFAULT NULL COMMENT 'Apgar 5分钟评分',
|
||||
`collector_user_id` varchar(64) DEFAULT NULL COMMENT '采集专员用户ID',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rdp_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠单-当事人客户信息';
|
||||
|
||||
-- 科研捐赠单-采集信息
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_collection` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠单ID',
|
||||
`region_id` varchar(64) DEFAULT NULL COMMENT '区域ID',
|
||||
`hospital_id` varchar(64) DEFAULT NULL COMMENT '医院ID',
|
||||
`medical_staff` varchar(200) DEFAULT NULL COMMENT '医护人员',
|
||||
`sales_user_id` varchar(64) DEFAULT NULL COMMENT '销售人员用户ID',
|
||||
`sample_collect_time` datetime DEFAULT NULL COMMENT '样本采集时间',
|
||||
`maternal_blood_collect_time` datetime DEFAULT NULL COMMENT '母血采集时间',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rdc_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠单-采集信息';
|
||||
|
||||
-- 科研捐赠单-样本信息
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_sample` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠单ID',
|
||||
`sample_type_id` varchar(64) DEFAULT NULL COMMENT '样本类型ID',
|
||||
`sample_code_id` varchar(64) DEFAULT NULL COMMENT '样本编码ID',
|
||||
`sample_code` varchar(32) DEFAULT NULL COMMENT '样本编码',
|
||||
`collect_amount` decimal(18,4) DEFAULT NULL COMMENT '采集量',
|
||||
`unit_id` varchar(64) DEFAULT NULL COMMENT '单位ID',
|
||||
`storage_years` int(11) DEFAULT NULL COMMENT '存储年限',
|
||||
`sample_status` varchar(16) DEFAULT NULL COMMENT '样本状态',
|
||||
`storage_in_date` char(8) DEFAULT NULL COMMENT '入库日期YYYYMMDD',
|
||||
`detect_start_date` char(8) DEFAULT NULL COMMENT '检测开始日期YYYYMMDD',
|
||||
`void_date` char(8) DEFAULT NULL COMMENT '作废日期YYYYMMDD',
|
||||
`storage_expire_date` char(8) DEFAULT NULL COMMENT '存储到期日期YYYYMMDD',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rds_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠单-样本信息';
|
||||
|
||||
-- 科研捐赠单-样本运输
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_sample_transport` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠单ID',
|
||||
`sample_id` varchar(64) NOT NULL COMMENT '样本ID',
|
||||
`transport_type` varchar(16) NOT NULL COMMENT '运输方式 kdys快递运输 ryxd人员携带',
|
||||
`express_company_id` varchar(64) DEFAULT NULL COMMENT '快递公司ID',
|
||||
`waybill_no` varchar(128) DEFAULT NULL COMMENT '快递单号',
|
||||
`dest_site_id` varchar(64) DEFAULT NULL COMMENT '目的站点ID',
|
||||
`express_fee` decimal(18,2) DEFAULT NULL COMMENT '快递费用(元)',
|
||||
`eta_time` datetime DEFAULT NULL COMMENT '预计到达时间',
|
||||
`carrier_name` varchar(100) DEFAULT NULL COMMENT '携带人员姓名',
|
||||
`carrier_phone` varchar(32) DEFAULT NULL COMMENT '携带人员联系电话',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rdst_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠单-样本运输';
|
||||
|
||||
-- ============================================================
|
||||
-- 科研捐赠管理 — sys_menu 菜单数据(ID 与 sqxp.ts 路由 ID 对应)
|
||||
-- parent_id = '2' 表示挂在 Web 端菜单根节点下
|
||||
-- ============================================================
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000001', '科研捐赠管理', '2', 'M', '/research-donation', '/index/index', NULL, 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000002', '捐赠协议单', '60000000000000000000000000000001', 'C', 'list', '/research-donation/list/index', 'biz:researchDonation:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`) VALUES ('60000000000000000000000000000003', '捐赠协议审核', '60000000000000000000000000000001', 'C', 'audit', '/research-donation/audit/index', 'biz:researchDonationAudit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 中试服务管理菜单 (Web端,parent_id=2 为 Web 端根菜单)
|
||||
-- 与前端路由 sqxp.ts 中 PilotService 路由块对应
|
||||
|
||||
-- 一级菜单目录
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES ('80000000000000000000000000000001', '中试服务管理', '2', 'M', '/pilot-service', NULL, NULL, 1, '1', '1', 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- 二级菜单
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES ('80000000000000000000000000000002', '细胞产品类型管理', '80000000000000000000000000000001', 'C', 'cell-product-type', 'pilot-service/cell-product-type/index', NULL, 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES ('80000000000000000000000000000003', '细胞产品规格管理', '80000000000000000000000000000001', 'C', 'cell-product-spec', 'pilot-service/cell-product-spec/index', NULL, 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES ('80000000000000000000000000000004', '细胞产品价格体系管理', '80000000000000000000000000000001', 'C', 'cell-product-price', 'pilot-service/cell-product-price/index', NULL, 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES ('80000000000000000000000000000005', '细胞产品项目价格管理', '80000000000000000000000000000001', 'C', 'project-price-system', 'pilot-service/project-price-system/index', NULL, 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES ('80000000000000000000000000000006', '渠道信息管理', '80000000000000000000000000000001', 'C', 'channel', 'pilot-service/channel/index', NULL, 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES ('80000000000000000000000000000007', '体检机构及项目价格', '80000000000000000000000000000001', 'C', 'checkup-institution', 'pilot-service/checkup-institution/index', NULL, 1, '1', '1', 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
+64
-1
@@ -370,7 +370,7 @@ CREATE TABLE `biz_channel_agreement` (
|
||||
`credit_limit_amount` decimal(18,2) DEFAULT NULL COMMENT '授信额度(元)',
|
||||
`effective_date` char(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效日期YYYYMMDD',
|
||||
`expire_date` char(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '到期日期YYYYMMDD',
|
||||
`agreement_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'sxz' COMMENT 'sxz生效中 yzf已作废',
|
||||
`agreement_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'dsx' COMMENT 'dsx待生效 sxz生效中 yzf已作废',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
@@ -649,6 +649,7 @@ CREATE TABLE `biz_checkup_institution_project` (
|
||||
`checkup_institution_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '体检机构ID',
|
||||
`project_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目名称',
|
||||
`project_fee` decimal(18,2) DEFAULT NULL COMMENT '项目费用(元)',
|
||||
`status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '1' COMMENT '1正常 0停用',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
@@ -668,6 +669,68 @@ CREATE TABLE `biz_checkup_institution_project` (
|
||||
BEGIN;
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for biz_checkup_institution_package
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `biz_checkup_institution_package_item`;
|
||||
DROP TABLE IF EXISTS `biz_checkup_institution_package`;
|
||||
CREATE TABLE `biz_checkup_institution_package` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`checkup_institution_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '体检机构ID',
|
||||
`package_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '套餐名称',
|
||||
`package_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '套餐编码(必填,机构内未删除记录唯一,应用层校验)',
|
||||
`status` char(1) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '1' COMMENT '1正常 0停用',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ck_pkg_inst` (`checkup_institution_id`,`del_flag`),
|
||||
KEY `idx_ck_pkg_code` (`checkup_institution_id`,`package_code`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体检机构项目套餐';
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of biz_checkup_institution_package
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for biz_checkup_institution_package_item
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `biz_checkup_institution_package_item`;
|
||||
CREATE TABLE `biz_checkup_institution_package_item` (
|
||||
`id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '主键ID',
|
||||
`package_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '套餐ID',
|
||||
`checkup_institution_project_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '机构项目ID',
|
||||
`project_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目名称(快照)',
|
||||
`project_fee` decimal(18,2) DEFAULT NULL COMMENT '套餐内项目费用(元),可独立于机构项目价',
|
||||
`tenant_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ck_pkg_item` (`package_id`,`checkup_institution_project_id`),
|
||||
KEY `idx_ck_pkg_item_pkg` (`package_id`,`del_flag`),
|
||||
KEY `idx_ck_pkg_item_proj` (`checkup_institution_project_id`,`del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体检机构项目套餐明细';
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of biz_checkup_institution_package_item
|
||||
-- ----------------------------
|
||||
BEGIN;
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for biz_clean_room
|
||||
-- ----------------------------
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
# 中试服务管理 6 模块实现计划
|
||||
|
||||
> **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:** 实现中试服务管理下 6 个基础数据 CRUD 模块(后端 API + 前端页面 + 路由 + 菜单),覆盖细胞产品类型/规格/价格体系/项目价格、渠道信息、体检机构。
|
||||
|
||||
**Architecture:** 后端单文件 `pilot_service.py`(CRUD + Route),Model 追加到 `models.py`;前端 6 个 Vue 页面 + API 函数追加到 `admin.ts`;菜单 SQL 独立文件。所有模块严格复制现有 `customer.py` / `item_unit.py` / `basic_info.py` 模式。
|
||||
|
||||
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2(后端);Vue 3 / TypeScript / Element Plus / Tailwind CSS 4(前端);MySQL 5.7
|
||||
|
||||
**Design Spec:** `docs/superpowers/specs/2026-05-17-pilot-service-management-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| # | 文件 | 操作 | 职责 |
|
||||
|---|------|------|------|
|
||||
| 1 | `backend/app/models/models.py` | 追加 | 13 个 SQLAlchemy Model |
|
||||
| 2 | `backend/app/crud/pilot_service.py` | 新建 | 全部 6 模块 CRUD 函数 |
|
||||
| 3 | `backend/app/api/pilot_service.py` | 新建 | 全部 6 模块路由 + Pydantic Body |
|
||||
| 4 | `backend/app/api/__init__.py` | 追加 | import + __all__ |
|
||||
| 5 | `backend/app/main.py` | 追加 | include_router |
|
||||
| 6 | `web-admin/src/api/admin.ts` | 追加 | API 函数 |
|
||||
| 7-12 | `web-admin/src/views/pilot-service/{module}/index.vue` | 新建 ×6 | 前端页面 |
|
||||
| 13 | `web-admin/src/router/modules/sqxp.ts` | 追加 | 路由配置 |
|
||||
| 14 | `docs/sql/sys_menu_pilot_service.sql` | 新建 | 菜单 INSERT |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端 — 新增 13 个 SQLAlchemy Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/models.py` (在 L1354 `BizExhibitionAppointment` 之后、L1356 qc_param import 之前追加)
|
||||
|
||||
- [ ] **Step 1: 在 `models.py` 末尾(qc_param import 行之前)追加 13 个 Model 类**
|
||||
|
||||
在 `BizExhibitionAppointment` 类定义之后、`from app.models.qc_param import ...` 行之前,追加以下 13 个 Model。每个 Model 严格遵循 `id → 业务字段 → 公共字段` 顺序,列定义与 `zkxw.sql` 中 CREATE TABLE 语句一一对应。
|
||||
|
||||
需要追加的 13 个类:
|
||||
|
||||
```
|
||||
# 2.3.1 细胞产品类型
|
||||
BizCellProductType(biz_cell_product_type)
|
||||
id, product_type_code, product_type_name → 公共字段
|
||||
|
||||
# 2.3.2 细胞产品规格
|
||||
BizCellProductSpec(biz_cell_product_spec)
|
||||
id, spec_code, spec_name → 公共字段
|
||||
|
||||
# 2.3.3 细胞产品价格体系
|
||||
BizCellProductPrice(biz_cell_product_price)
|
||||
id, project_name, project_code, cell_type_code, cell_type_name, spec_text, spec_id, cell_product_type_id, unit_price(Numeric(18,2)) → 公共字段
|
||||
|
||||
# 2.3.4 细胞产品项目价格体系(主表)
|
||||
BizCellProductProjectSystem(biz_cell_product_project_system)
|
||||
id, system_name, system_code, status(CHAR(1), default="1") → 公共字段
|
||||
|
||||
# 2.3.4 细胞产品项目价格体系(明细子表)
|
||||
BizCellProductProjectSystemItem(biz_cell_product_project_system_item)
|
||||
id, project_system_id, source_price_id, project_name, project_code, cell_type_code, cell_type_name, spec_text, spec_id, cell_product_type_id, unit_price(Numeric(18,2)) → 公共字段
|
||||
|
||||
# 2.3.5 渠道信息(主表)
|
||||
BizChannel(biz_channel)
|
||||
id, channel_name, credit_code, channel_type, contact_name, contact_phone, contact_email, protocol_attachments(String(4000)) → 公共字段
|
||||
|
||||
# 2.3.5 渠道协议
|
||||
BizChannelAgreement(biz_channel_agreement)
|
||||
id, channel_id, agreement_type, business_type, settle_mode, credit_limit_amount(Numeric(18,2)), effective_date(CHAR(8)), expire_date(CHAR(8)), agreement_status(default="sxz") → 公共字段
|
||||
|
||||
# 2.3.5 渠道协议-产品价格
|
||||
BizChannelAgreementProductPrice(biz_channel_agreement_product_price)
|
||||
id, channel_agreement_id, project_name, project_code, cell_type_code, cell_type_name, spec_text, original_price(Numeric(18,2)), agreement_price(Numeric(18,2)) → 公共字段
|
||||
|
||||
# 2.3.5 渠道开票信息
|
||||
BizChannelInvoice(biz_channel_invoice)
|
||||
id, channel_id, company_name, tax_no, company_address, company_phone, bank_name, bank_account → 公共字段
|
||||
|
||||
# 2.3.5 渠道邮寄地址
|
||||
BizChannelMailAddress(biz_channel_mail_address)
|
||||
id, channel_id, mail_address, mail_contact, mail_phone → 公共字段
|
||||
|
||||
# 2.3.6 体检机构
|
||||
BizCheckupInstitution(biz_checkup_institution)
|
||||
id, institution_name, institution_address, contact_name, contact_phone, status(CHAR(1), default="1") → 公共字段
|
||||
|
||||
# 2.3.6 体检机构开票信息
|
||||
BizCheckupInstitutionInvoice(biz_checkup_institution_invoice)
|
||||
id, checkup_institution_id, company_name, tax_no, company_address, company_phone, bank_name, bank_account → 公共字段
|
||||
|
||||
# 2.3.6 体检机构项目及价格
|
||||
BizCheckupInstitutionProject(biz_checkup_institution_project)
|
||||
id, checkup_institution_id, project_name, project_fee(Numeric(18,2)) → 公共字段
|
||||
```
|
||||
|
||||
每个类的公共字段(从 `tenant_no` 到 `del_flag`)完全复制 `BizExhibitionAppointment` 的模式。
|
||||
|
||||
- [ ] **Step 2: 验证 Model 无语法错误**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.models.models import BizCellProductType, BizCellProductSpec, BizCellProductPrice, BizCellProductProjectSystem, BizCellProductProjectSystemItem, BizChannel, BizChannelAgreement, BizChannelAgreementProductPrice, BizChannelInvoice, BizChannelMailAddress, BizCheckupInstitution, BizCheckupInstitutionInvoice, BizCheckupInstitutionProject; print('All 13 models imported OK')"`
|
||||
Expected: `All 13 models imported OK`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/models.py
|
||||
git commit -m "feat: add 13 SQLAlchemy models for pilot service management (2.3.1-2.3.6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 — 创建 CRUD 文件
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/pilot_service.py`
|
||||
|
||||
- [ ] **Step 1: 创建 `backend/app/crud/pilot_service.py`**
|
||||
|
||||
单文件,按模块分段组织。文件结构:
|
||||
|
||||
```python
|
||||
"""中试服务管理 CRUD(细胞产品类型/规格/价格体系/项目价格、渠道信息、体检机构)"""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizCellProductType, BizCellProductSpec, BizCellProductPrice,
|
||||
BizCellProductProjectSystem, BizCellProductProjectSystemItem,
|
||||
BizChannel, BizChannelAgreement, BizChannelAgreementProductPrice,
|
||||
BizChannelInvoice, BizChannelMailAddress,
|
||||
BizCheckupInstitution, BizCheckupInstitutionInvoice, BizCheckupInstitutionProject,
|
||||
)
|
||||
```
|
||||
|
||||
每个实体遵循 `customer.py` 的完整模式:
|
||||
1. `_row_to_dict_xxx()` — camelCase 转换
|
||||
2. `list_xxx()` — 分页 + keyword + tenant_no 过滤
|
||||
3. `get_xxx()` — 单条查询
|
||||
4. `xxx_code_exists()` — 编码唯一性校验(有编码字段的实体)
|
||||
5. `create_xxx()` — 新增(generate_uuid, validate, commit+refresh)
|
||||
6. `update_xxx()` — 更新(validate, commit+refresh)
|
||||
7. `soft_delete_xxx()` — 软删除(del_flag=1)
|
||||
|
||||
**简单实体(复制 customer.py 模式,字段替换)**:
|
||||
- `BizCellProductType` — keyword 搜索 product_type_code / product_type_name
|
||||
- `BizCellProductSpec` — keyword 搜索 spec_code / spec_name
|
||||
- `BizCellProductPrice` — keyword 搜索 project_name / project_code / cell_type_name;额外接受 `cell_type_code` 过滤参数
|
||||
|
||||
**主子表实体(主表 CRUD + 子表 CRUD)**:
|
||||
|
||||
`BizCellProductProjectSystem` + `BizCellProductProjectSystemItem`:
|
||||
- 主表:标准 7 函数
|
||||
- 子表:`list_project_system_items(db, project_system_id, tenant_no)` + `create_project_system_item()` + `update_project_system_item()` + `soft_delete_project_system_item()`
|
||||
- `_row_to_dict_project_system_item()` 中包含完整字段
|
||||
|
||||
`BizChannel` + 4 张子表:
|
||||
- 主表 BizChannel:标准 7 函数,keyword 搜索 channel_name / credit_code / contact_name
|
||||
- 子表 BizChannelInvoice:标准子表 CRUD(by channel_id)
|
||||
- 子表 BizChannelMailAddress:标准子表 CRUD(by channel_id)
|
||||
- 子表 BizChannelAgreement:标准子表 CRUD(by channel_id)+ `activate_channel_agreement()` (设置 agreement_status="sxz") + `void_channel_agreement()` (设置 agreement_status="yzf")
|
||||
- 子表 BizChannelAgreementProductPrice:标准子表 CRUD(by channel_agreement_id)
|
||||
|
||||
`BizCheckupInstitution` + 2 张子表:
|
||||
- 主表 BizCheckupInstitution:标准 7 函数
|
||||
- 子表 BizCheckupInstitutionInvoice:标准子表 CRUD(by checkup_institution_id)
|
||||
- 子表 BizCheckupInstitutionProject:标准子表 CRUD(by checkup_institution_id)
|
||||
|
||||
末尾提供 `total_pages(total, page_size)` 工具函数(与 customer.py 一致)。
|
||||
|
||||
- [ ] **Step 2: 验证 CRUD 文件可导入**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.crud import pilot_service; print('CRUD imported OK')"`
|
||||
Expected: `CRUD imported OK`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/pilot_service.py
|
||||
git commit -m "feat: add CRUD functions for pilot service management (6 modules, 13 entities)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 — 创建路由文件 + 注册
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/pilot_service.py`
|
||||
- Modify: `backend/app/api/__init__.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
|
||||
- [ ] **Step 1: 创建 `backend/app/api/pilot_service.py`**
|
||||
|
||||
复制 `basic_info.py` 的头部模式:
|
||||
|
||||
```python
|
||||
"""中试服务管理 API(细胞产品类型/规格/价格体系/项目价格、渠道信息、体检机构)"""
|
||||
from typing import Any, List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import pilot_service as ps_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["中试服务管理"])
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
if not sys_user or not sys_user.tenant_no:
|
||||
raise PermissionError("用户租户信息缺失")
|
||||
return sys_user.tenant_no
|
||||
```
|
||||
|
||||
**每个简单实体提供 4 个端点**(GET list / POST create / PUT update / DELETE),完全复制 `basic_info.py` 中客户管理的模式。
|
||||
|
||||
**端点清单**:
|
||||
|
||||
简单实体(4 端点 × 3):
|
||||
- `/cell-product-types` — 2.3.1
|
||||
- `/cell-product-specs` — 2.3.2
|
||||
- `/cell-product-prices` — 2.3.3(CreateBody 额外含 cell_type_code, cell_type_name, spec_id, spec_text, cell_product_type_id, unit_price)
|
||||
|
||||
主子表实体(主表 4 端点 + 子表 4 端点):
|
||||
- `/project-price-systems` — 2.3.4 主表
|
||||
- `/project-price-systems/{id}/items` GET — 列出子表
|
||||
- `/project-price-systems/{id}/items` POST — 新增子表行
|
||||
- `/project-system-items/{id}` PUT — 更新子表行
|
||||
- `/project-system-items/{id}` DELETE — 删除子表行
|
||||
|
||||
渠道(主表 4 端点 + 各子表端点):
|
||||
- `/channels` — 2.3.5 主表 CRUD
|
||||
- `/channels/{id}/invoices` GET/POST — 开票信息
|
||||
- `/channel-invoices/{id}` PUT/DELETE — 开票信息
|
||||
- `/channels/{id}/mail-addresses` GET/POST — 邮寄地址
|
||||
- `/channel-mail-addresses/{id}` PUT/DELETE — 邮寄地址
|
||||
- `/channels/{id}/agreements` GET/POST — 协议
|
||||
- `/channel-agreements/{id}` PUT/DELETE — 协议
|
||||
- `/channel-agreements/{id}/activate` PUT — 协议生效
|
||||
- `/channel-agreements/{id}/void` PUT — 协议作废
|
||||
- `/channel-agreements/{id}/product-prices` GET/POST — 协议产品价格
|
||||
- `/channel-agreement-product-prices/{id}` PUT/DELETE — 协议产品价格
|
||||
|
||||
体检机构(主表 4 端点 + 子表端点):
|
||||
- `/checkup-institutions` — 2.3.6 主表 CRUD
|
||||
- `/checkup-institutions/{id}/invoices` GET/POST — 开票信息
|
||||
- `/checkup-institution-invoices/{id}` PUT/DELETE — 开票信息
|
||||
- `/checkup-institutions/{id}/projects` GET/POST — 项目及价格
|
||||
- `/checkup-institution-projects/{id}` PUT/DELETE — 项目及价格
|
||||
|
||||
**Pydantic Body Models** 定义在路由文件内部(与 `basic_info.py` 一致),使用 `Field(..., alias="camelCase")` 映射。
|
||||
|
||||
**List 端点**暂时使用 `require_admin_or_super_admin`(与 `admin.py` 中物品单位管理一致),不引入 menu_code。后续菜单配置完成后再按需调整。
|
||||
|
||||
- [ ] **Step 2: 注册路由到 `__init__.py`**
|
||||
|
||||
在 `backend/app/api/__init__.py` 中追加:
|
||||
```python
|
||||
from app.api.pilot_service import router as pilot_service_router
|
||||
```
|
||||
并在 `__all__` 列表中追加 `"pilot_service_router"`。
|
||||
|
||||
- [ ] **Step 3: 注册路由到 `main.py`**
|
||||
|
||||
在 `backend/app/main.py` 中:
|
||||
- import 行追加 `from app.api import ..., pilot_service_router`
|
||||
- 在已有的 `app.include_router(...)` 行之后追加:
|
||||
`app.include_router(pilot_service_router, prefix=settings.API_V1_PREFIX)`
|
||||
|
||||
- [ ] **Step 4: 验证路由注册成功**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.main import app; routes = [r.path for r in app.routes]; print(any('cell-product-types' in str(r) for r in routes))"`
|
||||
Expected: `True`
|
||||
|
||||
- [ ] **Step 5: 启动服务验证无报错**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && timeout 5 python -c "import uvicorn; from app.main import app; print('App loads OK')" 2>&1`
|
||||
Expected: `App loads OK`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/pilot_service.py backend/app/api/__init__.py backend/app/main.py
|
||||
git commit -m "feat: add pilot service API routes + registration (6 modules, 13 entities)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 前端 — API 函数
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/admin.ts`
|
||||
|
||||
- [ ] **Step 1: 在 `admin.ts` 末尾追加所有 API 函数**
|
||||
|
||||
每个实体 4 个函数(fetchXxxList / fetchCreateXxx / fetchUpdateXxx / fetchDeleteXxx),遵循现有模式:
|
||||
|
||||
```typescript
|
||||
// URL 模式: GET /web/admin/cell-product-types, POST /web/admin/cell-product-types, 等
|
||||
// request.post 使用 params 而非 data(HTTP client 自动处理)
|
||||
```
|
||||
|
||||
需要追加的函数组:
|
||||
|
||||
**2.3.1 细胞产品类型** (4 函数)
|
||||
**2.3.2 细胞产品规格** (4 函数)
|
||||
**2.3.3 细胞产品价格体系** (4 函数,list 额外支持 cellTypeCode 过滤参数)
|
||||
**2.3.4 项目价格体系主表** (4 函数)
|
||||
**2.3.4 项目价格体系子表** (4 函数 + fetchProjectSystemItems)
|
||||
**2.3.5 渠道主表** (4 函数)
|
||||
**2.3.5 渠道开票信息子表** (4 函数 + fetchChannelInvoices)
|
||||
**2.3.5 渠道邮寄地址子表** (4 函数 + fetchChannelMailAddresses)
|
||||
**2.3.5 渠道协议子表** (4 函数 + fetchChannelAgreements + fetchActivateChannelAgreement + fetchVoidChannelAgreement)
|
||||
**2.3.5 渠道协议价格子表** (4 函数 + fetchChannelAgreementPrices)
|
||||
**2.3.6 体检机构主表** (4 函数)
|
||||
**2.3.6 体检机构开票信息子表** (4 函数 + fetchCheckupInstitutionInvoices)
|
||||
**2.3.6 体检机构项目子表** (4 函数 + fetchCheckupInstitutionProjects)
|
||||
|
||||
总计约 50+ API 函数。
|
||||
|
||||
- [ ] **Step 2: 验证 TypeScript 无报错**
|
||||
|
||||
Run: `cd web-admin && npx tsc --noEmit src/api/admin.ts 2>&1 | head -20`
|
||||
Expected: 无错误输出(或仅与本次修改无关的已有警告)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/admin.ts
|
||||
git commit -m "feat: add frontend API functions for pilot service management (6 modules)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端 — 细胞产品类型管理页面 (2.3.1)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pilot-service/cell-product-type/index.vue`
|
||||
|
||||
- [ ] **Step 1: 创建页面文件**
|
||||
|
||||
复制 `web-admin/src/views/system/unit/index.vue` 作为模板,修改为:
|
||||
|
||||
- `defineOptions({ name: 'PilotCellProductType' })`
|
||||
- API 调用替换为 `fetchCellProductTypeList` / `fetchCreateCellProductType` / `fetchUpdateCellProductType` / `fetchDeleteCellProductType`
|
||||
- import 来源 `@/api/admin`
|
||||
- 列表列:序号、类型编码(`productTypeCode`)、类型名称(`productTypeName`)、备注(`remark`)、创建时间(`createTime`)、操作
|
||||
- 表单字段:类型编码(必填)、类型名称(必填)、备注
|
||||
- 搜索:keyword 搜索编码/名称
|
||||
- 标题:`细胞产品类型管理`
|
||||
- 保持 `unit/index.vue` 的所有 CSS 类和布局结构不变
|
||||
- 操作列按钮:查看、编辑、删除
|
||||
- 不展示排序字段(按 AGENTS.md 约定)
|
||||
|
||||
- [ ] **Step 2: 验证页面可正常加载(dev server)**
|
||||
|
||||
Run: `cd web-admin && npx vue-tsc --noEmit src/views/pilot-service/cell-product-type/index.vue 2>&1 | head -10`
|
||||
Expected: 无错误
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/cell-product-type/index.vue
|
||||
git commit -m "feat: add cell product type management page (2.3.1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 前端 — 细胞产品规格管理页面 (2.3.2)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pilot-service/cell-product-spec/index.vue`
|
||||
|
||||
- [ ] **Step 1: 创建页面文件**
|
||||
|
||||
复制 Task 5 的 `cell-product-type/index.vue`,修改为:
|
||||
|
||||
- `defineOptions({ name: 'PilotCellProductSpec' })`
|
||||
- API 调用替换为 `fetchCellProductSpecList` / `fetchCreateCellProductSpec` / ...
|
||||
- 列表列:序号、规格编码(`specCode`)、规格名称(`specName`)、备注、创建时间、操作
|
||||
- 表单字段:规格编码(必填)、规格名称(必填)、备注
|
||||
- 标题:`细胞产品规格管理`
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/cell-product-spec/index.vue
|
||||
git commit -m "feat: add cell product spec management page (2.3.2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 前端 — 细胞产品价格体系管理页面 (2.3.3)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pilot-service/cell-product-price/index.vue`
|
||||
|
||||
- [ ] **Step 1: 创建页面文件**
|
||||
|
||||
复制 Task 5 的 `cell-product-type/index.vue`,扩展为:
|
||||
|
||||
- `defineOptions({ name: 'PilotCellProductPrice' })`
|
||||
- API 调用替换为对应函数
|
||||
- 列表列:序号、项目名称(`projectName`)、项目编码(`projectCode`)、细胞类型名称(`cellTypeName`)、规格(`specText`)、单价(`unitPrice`)、备注、创建时间、操作
|
||||
- **新增/编辑弹窗增加下拉关联**:
|
||||
- 细胞产品类型:`el-select` 调用 `fetchCellProductTypeList({ page: 1, pageSize: 999 })` 获取选项,选择后填充 `cellTypeCode` + `cellTypeName` + `cellProductTypeId`
|
||||
- 规格:`el-select` 调用 `fetchCellProductSpecList({ page: 1, pageSize: 999 })` 获取选项,选择后填充 `specId` + `specText`
|
||||
- 单价:`el-input-number` (precision=2, min=0)
|
||||
- 表单字段:项目名称(必填)、项目编码(必填)、细胞产品类型(下拉)、规格(下拉)、单价(元)、备注
|
||||
- import 新增:`fetchCellProductTypeList`, `fetchCellProductSpecList`
|
||||
- 标题:`细胞产品价格体系管理`
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/cell-product-price/index.vue
|
||||
git commit -m "feat: add cell product price system page with dropdown relations (2.3.3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 — 细胞产品项目价格管理页面 (2.3.4)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pilot-service/project-price-system/index.vue`
|
||||
|
||||
- [ ] **Step 1: 创建页面文件**
|
||||
|
||||
参考 `web-admin/src/views/system/category/index.vue` 主子表模式,构建:
|
||||
|
||||
- `defineOptions({ name: 'PilotProjectPriceSystem' })`
|
||||
- **列表区**:主表分页列表
|
||||
- 列:序号、体系名称(`systemName`)、体系编码(`systemCode`)、状态(tag: 正常/停用)、备注、创建时间、操作
|
||||
- 操作按钮:查看、编辑、删除
|
||||
- **新增/编辑弹窗**:
|
||||
- 上半部分:主表字段(体系名称必填、体系编码必填、状态下拉、备注)
|
||||
- 下半部分:`el-table` 明细表(可编辑)
|
||||
- 列:项目名称、项目编码、细胞类型名称、规格、单价(可编辑 el-input-number)、操作(删除)
|
||||
- 底部按钮:"从价格体系添加" — 弹出 `el-dialog` 选择价格体系(2.3.3)的行,选择后自动填充明细行数据
|
||||
- 支持手动修改 `unitPrice`
|
||||
- 保存时:先 save 主表 → 获取 id → 逐行 save 子表
|
||||
- import:`fetchProjectSystemList`, `fetchCreateProjectSystem`, ... 以及 `fetchCellProductPriceList`(用于选择添加)
|
||||
- 标题:`细胞产品项目价格管理`
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/project-price-system/index.vue
|
||||
git commit -m "feat: add project price system page with master-detail table (2.3.4)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 前端 — 渠道信息管理页面 (2.3.5)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pilot-service/channel/index.vue`
|
||||
|
||||
- [ ] **Step 1: 创建页面文件**
|
||||
|
||||
参考 `web-admin/src/views/system/product/index.vue` 多 section 模式,构建:
|
||||
|
||||
- `defineOptions({ name: 'PilotChannel' })`
|
||||
- **列表区**:渠道分页列表
|
||||
- 列:序号、渠道名称(`channelName`)、统一社会信用代码(`creditCode`)、渠道类型(tag: yy医院/hzjg合作机构/zs诊所/zfjg政府机构/qt其他)、联系人(`contactName`)、联系电话(`contactPhone`)、创建时间、操作
|
||||
- **新增/编辑弹窗**(宽度 `1000px`,使用 `el-tabs`):
|
||||
- **Tab 1 基础信息**:渠道名称(必填)、统一社会信用代码、渠道类型(下拉)、联系人姓名、联系电话、联系邮箱、渠道协议附件(上传)、备注
|
||||
- **Tab 2 开票信息**:`el-table` 子表 + 新增/编辑/删除按钮
|
||||
- 弹窗表单:单位名称(必填)、税号、单位地址、电话、开户银行、银行账号
|
||||
- **Tab 3 邮寄地址**:`el-table` 子表 + 新增/编辑/删除按钮
|
||||
- 弹窗表单:邮寄地址(必填)、邮寄联系人、邮寄联系电话
|
||||
- **Tab 4 协议信息**:`el-table` 子表 + 新增/编辑/删除按钮
|
||||
- 弹窗表单:协议类型(必填,下拉 kyxy/zsxy/lcxy)、结算方式(下拉)、授信额度、生效日期、到期日期
|
||||
- 每行操作按钮:查看价格明细、生效、作废
|
||||
- **价格明细子弹窗**:`el-table` 显示协议价格
|
||||
- 底部按钮:"从价格体系添加"、"从项目价格体系添加"
|
||||
- 选择后自动填充项目名称/编码/细胞类型/规格/原价,协议价需手动输入
|
||||
- 支持编辑协议价、删除行
|
||||
- import:渠道相关 API + `fetchCellProductPriceList` + `fetchProjectSystemList` + `fetchProjectSystemItems`
|
||||
- 标题:`渠道信息管理`
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/channel/index.vue
|
||||
git commit -m "feat: add channel management page with multi-tab sub-tables (2.3.5)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 前端 — 体检机构及项目价格页面 (2.3.6)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pilot-service/checkup-institution/index.vue`
|
||||
|
||||
- [ ] **Step 1: 创建页面文件**
|
||||
|
||||
参考 `category/index.vue` 主子表模式 + Task 9 的 tab 模式:
|
||||
|
||||
- `defineOptions({ name: 'PilotCheckupInstitution' })`
|
||||
- **列表区**:分页列表
|
||||
- 列:序号、机构名称(`institutionName`)、机构地址(`institutionAddress`)、联系人(`contactName`)、联系电话(`contactPhone`)、状态(tag)、创建时间、操作
|
||||
- **新增/编辑弹窗**(宽度 `900px`,`el-tabs`):
|
||||
- **Tab 1 基础信息**:机构名称(必填)、机构地址、联系人、联系电话、状态(正常/停用)、备注
|
||||
- **Tab 2 开票信息**:`el-table` 子表 + 新增/编辑/删除
|
||||
- 弹窗表单:单位名称(必填)、税号、单位地址、电话、开户银行、银行账号
|
||||
- **Tab 3 项目及价格**:`el-table` 子表 + 新增/编辑/删除
|
||||
- 弹窗表单:项目名称(必填)、项目费用(元, el-input-number)
|
||||
- 标题:`体检机构及项目价格`
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/checkup-institution/index.vue
|
||||
git commit -m "feat: add checkup institution management page with sub-tables (2.3.6)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 前端路由 + 菜单 SQL
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||||
- Create: `docs/sql/sys_menu_pilot_service.sql`
|
||||
|
||||
- [ ] **Step 1: 在 `sqxp.ts` 中追加中试服务管理路由块**
|
||||
|
||||
在 `CellStorage` 路由块之后追加新的路由组:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: '80000000000000000000000000000001',
|
||||
name: 'PilotService',
|
||||
path: '/pilot-service',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '中试服务管理',
|
||||
icon: 'ri:flask-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '80000000000000000000000000000002',
|
||||
name: 'CellProductType',
|
||||
path: 'cell-product-type',
|
||||
component: '/pilot-service/cell-product-type/index',
|
||||
meta: { title: '细胞产品类型管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000003',
|
||||
name: 'CellProductSpec',
|
||||
path: 'cell-product-spec',
|
||||
component: '/pilot-service/cell-product-spec/index',
|
||||
meta: { title: '细胞产品规格管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000004',
|
||||
name: 'CellProductPrice',
|
||||
path: 'cell-product-price',
|
||||
component: '/pilot-service/cell-product-price/index',
|
||||
meta: { title: '细胞产品价格体系管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000005',
|
||||
name: 'ProjectPriceSystem',
|
||||
path: 'project-price-system',
|
||||
component: '/pilot-service/project-price-system/index',
|
||||
meta: { title: '细胞产品项目价格管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000006',
|
||||
name: 'Channel',
|
||||
path: 'channel',
|
||||
component: '/pilot-service/channel/index',
|
||||
meta: { title: '渠道信息管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000007',
|
||||
name: 'CheckupInstitution',
|
||||
path: 'checkup-institution',
|
||||
component: '/pilot-service/checkup-institution/index',
|
||||
meta: { title: '体检机构及项目价格', keepAlive: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
注意:追加在 `sqxpAdminRoutes` 数组中 `CellStorage` 路由块之后。
|
||||
|
||||
- [ ] **Step 2: 创建 `docs/sql/sys_menu_pilot_service.sql`**
|
||||
|
||||
```sql
|
||||
-- 中试服务管理菜单 (Web端,parent_id=2 为 Web 端根菜单)
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
|
||||
VALUES ('80000000000000000000000000000001', '中试服务管理', 2, 5, '/pilot-service', NULL, 'D', '1', '1', NULL, 'ri:flask-line', NOW(), NOW());
|
||||
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
|
||||
VALUES ('80000000000000000000000000000002', '细胞产品类型管理', '80000000000000000000000000000001', 1, 'cell-product-type', 'pilot-service/cell-product-type/index', 'C', '1', '1', NULL, NULL, NOW(), NOW());
|
||||
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
|
||||
VALUES ('80000000000000000000000000000003', '细胞产品规格管理', '80000000000000000000000000000001', 2, 'cell-product-spec', 'pilot-service/cell-product-spec/index', 'C', '1', '1', NULL, NULL, NOW(), NOW());
|
||||
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
|
||||
VALUES ('80000000000000000000000000000004', '细胞产品价格体系管理', '80000000000000000000000000000001', 3, 'cell-product-price', 'pilot-service/cell-product-price/index', 'C', '1', '1', NULL, NULL, NOW(), NOW());
|
||||
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
|
||||
VALUES ('80000000000000000000000000000005', '细胞产品项目价格管理', '80000000000000000000000000000001', 4, 'project-price-system', 'pilot-service/project-price-system/index', 'C', '1', '1', NULL, NULL, NOW(), NOW());
|
||||
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
|
||||
VALUES ('80000000000000000000000000000006', '渠道信息管理', '80000000000000000000000000000001', 5, 'channel', 'pilot-service/channel/index', 'C', '1', '1', NULL, NULL, NOW(), NOW());
|
||||
|
||||
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
|
||||
VALUES ('80000000000000000000000000000007', '体检机构及项目价格', '80000000000000000000000000000001', 6, 'checkup-institution', 'pilot-service/checkup-institution/index', 'C', '1', '1', NULL, NULL, NOW(), NOW());
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/router/modules/sqxp.ts docs/sql/sys_menu_pilot_service.sql
|
||||
git commit -m "feat: add pilot service routes and menu SQL (6 modules)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: 最终验证
|
||||
|
||||
- [ ] **Step 1: 后端启动验证**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && timeout 8 python -c "from app.main import app; print('Backend loads OK')" 2>&1`
|
||||
Expected: `Backend loads OK`
|
||||
|
||||
- [ ] **Step 2: 前端构建验证**
|
||||
|
||||
Run: `cd web-admin && pnpm build 2>&1 | tail -5`
|
||||
Expected: 构建成功,无错误
|
||||
|
||||
- [ ] **Step 3: LSP 诊断检查**
|
||||
|
||||
对以下文件运行 `lsp_diagnostics`:
|
||||
- `backend/app/api/pilot_service.py`
|
||||
- `backend/app/crud/pilot_service.py`
|
||||
- `web-admin/src/views/pilot-service/channel/index.vue`
|
||||
|
||||
Expected: 无 error 级别诊断
|
||||
|
||||
- [ ] **Step 4: Final commit (if any fixes needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: address final verification issues for pilot service management"
|
||||
```
|
||||
@@ -0,0 +1,815 @@
|
||||
# 生产管理模块 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:** Implement 7 production management modules (耗材管理、试剂管理、SOP文档、工艺工序分类、工艺产出物类型、耗材包管理、工艺库管理) with backend API + frontend UI, following existing codebase patterns exactly. 工艺路线配置 deferred to next iteration.
|
||||
|
||||
**Architecture:** FastAPI backend with flat-function CRUD (no service layer), Vue 3 + Element Plus frontend with standard list page template. Batch 1 (5 simple CRUD modules) has no dependencies. Batch 2 (耗材包+工艺库) depends on Batch 1. All modules share a new API route file `app/api/production.py` and frontend route group under `/production`.
|
||||
|
||||
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2 (backend); Vue 3 / TypeScript / Element Plus / Tailwind CSS 4 (frontend)
|
||||
|
||||
**Design Spec:** `docs/superpowers/specs/2026-05-17-production-management-design.md`
|
||||
|
||||
**Key Reference Files (READ FIRST):**
|
||||
- Backend CRUD pattern: `backend/app/crud/item_unit.py` — **canonical simple CRUD**
|
||||
- Backend API pattern: `backend/app/api/admin.py` (item-unit section, lines 968-1059)
|
||||
- Backend model pattern: `backend/app/models/models.py` (class BizItemUnit)
|
||||
- Frontend standard list: `web-admin/src/views/system/unit/index.vue` — **canonical list page**
|
||||
- Frontend API pattern: `web-admin/src/api/admin.ts` (fetchXxxList/fetchCreateXxx/fetchUpdateXxx/fetchDeleteXxx)
|
||||
- Frontend router: `web-admin/src/router/modules/sqxp.ts`
|
||||
- Upload pattern: `web-admin/src/api/business.ts` (fetchUploadAttachment)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend (create/modify)
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `docs/sql/production.sql` | CREATE | All 11 tables DDL + menu INSERT statements |
|
||||
| `backend/app/models/models.py` | MODIFY | Add 11 model classes at end of file |
|
||||
| `backend/app/crud/consumable.py` | CREATE | 耗材 CRUD |
|
||||
| `backend/app/crud/reagent.py` | CREATE | 试剂 CRUD |
|
||||
| `backend/app/crud/sop_document.py` | CREATE | SOP文档 CRUD |
|
||||
| `backend/app/crud/process_category.py` | CREATE | 工艺工序分类 CRUD |
|
||||
| `backend/app/crud/output_type.py` | CREATE | 工艺产出物类型 CRUD |
|
||||
| `backend/app/crud/consumable_pack.py` | CREATE | 耗材包 CRUD (主表+明细) |
|
||||
| `backend/app/crud/process.py` | CREATE | 工艺库 CRUD (主表+3子表) |
|
||||
| `backend/app/api/production.py` | CREATE | All 7 modules API routes |
|
||||
| `backend/app/api/__init__.py` | MODIFY | Import + export production_router |
|
||||
| `backend/app/main.py` | MODIFY | Import + register production_router |
|
||||
|
||||
### Frontend (create/modify)
|
||||
| File | Action | Purpose |
|
||||
|------|--------|---------|
|
||||
| `web-admin/src/api/production.ts` | CREATE | All 7 modules API functions |
|
||||
| `web-admin/src/router/modules/sqxp.ts` | MODIFY | Add 生产管理 route group |
|
||||
| `web-admin/src/views/production/consumable/index.vue` | CREATE | 耗材管理标准列表页 |
|
||||
| `web-admin/src/views/production/reagent/index.vue` | CREATE | 试剂管理标准列表页 |
|
||||
| `web-admin/src/views/production/sop/index.vue` | CREATE | SOP文档列表页(含附件上传) |
|
||||
| `web-admin/src/views/production/process-category/index.vue` | CREATE | 工艺工序分类标准列表页 |
|
||||
| `web-admin/src/views/production/output-type/index.vue` | CREATE | 工艺产出物类型标准列表页 |
|
||||
| `web-admin/src/views/production/consumable-pack/index.vue` | CREATE | 耗材包管理列表页(含明细子表) |
|
||||
| `web-admin/src/views/production/process/index.vue` | CREATE | 工艺库管理列表页(Tab式编辑) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SQL DDL + Menu Entries
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/production.sql`
|
||||
|
||||
- [ ] **Step 1: Create the SQL DDL file with all 11 tables and menu entries**
|
||||
|
||||
Create `docs/sql/production.sql` with the complete DDL. All tables follow project conventions: `id VARCHAR(64) UUID`, business fields first, then public fields (`tenant_no` through `del_flag`), `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`.
|
||||
|
||||
Tables to create:
|
||||
1. `biz_consumable` — consumable_code, consumable_name, spec_model, standard_usage, description
|
||||
2. `biz_reagent` — reagent_code, reagent_name, spec, standard_usage, unit, description
|
||||
3. `biz_sop_document` — sop_code, sop_name, version_no, sop_attachments
|
||||
4. `biz_process_category` — category_code, category_name
|
||||
5. `biz_output_type` — type_code, type_name
|
||||
6. `biz_consumable_pack` — pack_code, pack_name, usage_scene, pack_type, status
|
||||
7. `biz_consumable_pack_item` — pack_id, consumable_code, consumable_name, spec_model, standard_usage, remark
|
||||
8. `biz_process` — process_code, category_id, process_name, description, equipment_name, estimated_duration, sop_ids
|
||||
9. `biz_process_reagent` — process_id, reagent_code, reagent_name, spec, standard_usage, unit, is_key_material
|
||||
10. `biz_process_consumable` — process_id, consumable_code, consumable_name, spec_model, standard_usage, is_required, remark
|
||||
11. `biz_process_output` — process_id, output_type_id, output_name, output_barcode
|
||||
|
||||
Also include INSERT statements for `sys_menu`:
|
||||
- Top-level menu "生产管理" under Web root (parent_id pointing to the Web menu root, which has id='1')
|
||||
- 7 child menu items, each with appropriate `menu_code` (e.g., `production:consumable:list`, `production:reagent:list`, etc.)
|
||||
|
||||
**Verification:** File contains valid SQL, all tables have public fields, all tables use `VARCHAR(64)` for id, charset utf8mb4.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/sql/production.sql
|
||||
git commit -m "feat: add production management SQL DDL and menu entries"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend Models (11 SQLAlchemy models)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/models.py` (append at end)
|
||||
- Modify: `backend/app/models/__init__.py` (re-export new models)
|
||||
|
||||
- [ ] **Step 1: Add 11 model classes to models.py**
|
||||
|
||||
Append after the last class in `backend/app/models/models.py`. Follow the exact pattern of `BizItemUnit`:
|
||||
- Inherit from `Base` (from `app.core.database`)
|
||||
- All columns use `Column(...)` with explicit types
|
||||
- Public fields in exact order: tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
- `create_time` uses `default=datetime.now`, `update_time` uses `default=datetime.now, onupdate=datetime.now`
|
||||
- `del_flag` uses `SmallInteger` with `default=0`
|
||||
|
||||
Models:
|
||||
- `BizConsumable` → `biz_consumable`
|
||||
- `BizReagent` → `biz_reagent`
|
||||
- `BizSopDocument` → `biz_sop_document`
|
||||
- `BizProcessCategory` → `biz_process_category`
|
||||
- `BizOutputType` → `biz_output_type`
|
||||
- `BizConsumablePack` → `biz_consumable_pack`
|
||||
- `BizConsumablePackItem` → `biz_consumable_pack_item`
|
||||
- `BizProcess` → `biz_process`
|
||||
- `BizProcessReagent` → `biz_process_reagent`
|
||||
- `BizProcessConsumable` → `biz_process_consumable`
|
||||
- `BizProcessOutput` → `biz_process_output`
|
||||
|
||||
- [ ] **Step 2: Re-export in `backend/app/models/__init__.py`**
|
||||
|
||||
Add all 11 new classes to the imports and `__all__` list.
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.models.models import BizConsumable, BizReagent, BizSopDocument, BizProcessCategory, BizOutputType, BizConsumablePack, BizConsumablePackItem, BizProcess, BizProcessReagent, BizProcessConsumable, BizProcessOutput; print('All models imported OK')"`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/
|
||||
git commit -m "feat: add 11 production management SQLAlchemy models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Backend CRUD — Batch 1 (5 simple modules)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/consumable.py`
|
||||
- Create: `backend/app/crud/reagent.py`
|
||||
- Create: `backend/app/crud/sop_document.py`
|
||||
- Create: `backend/app/crud/process_category.py`
|
||||
- Create: `backend/app/crud/output_type.py`
|
||||
|
||||
Each CRUD file follows the EXACT pattern from `backend/app/crud/item_unit.py`:
|
||||
1. `_row_to_dict(obj)` — returns dict with camelCase keys
|
||||
2. `list_xxx(db, *, page, page_size, keyword, tenant_no)` — paginated list with keyword search + tenant filter + soft-delete filter
|
||||
3. `get_xxx(db, id)` — single record by id with del_flag=0 check
|
||||
4. `xxx_code_exists(db, code, tenant_no, exclude_id=None)` — uniqueness check on code field
|
||||
5. `create_xxx(db, *, ...fields..., tenant_no, user_id)` — validate + create with `generate_uuid()`
|
||||
6. `update_xxx(db, id, *, ...fields..., user_id)` — validate + update
|
||||
7. `soft_delete_xxx(db, id)` — set del_flag=1
|
||||
8. `total_pages(total, page_size)` — pagination helper
|
||||
|
||||
- [ ] **Step 1: Create `backend/app/crud/consumable.py`**
|
||||
|
||||
Fields: consumable_code, consumable_name, spec_model, standard_usage, description
|
||||
Keyword search on: consumable_code, consumable_name
|
||||
|
||||
- [ ] **Step 2: Create `backend/app/crud/reagent.py`**
|
||||
|
||||
Fields: reagent_code, reagent_name, spec, standard_usage, unit, description
|
||||
Keyword search on: reagent_code, reagent_name
|
||||
|
||||
- [ ] **Step 3: Create `backend/app/crud/sop_document.py`**
|
||||
|
||||
Fields: sop_code, sop_name, version_no, sop_attachments
|
||||
Keyword search on: sop_code, sop_name
|
||||
NOTE: `sop_attachments` stores comma-separated URLs, returned as-is in `_row_to_dict`
|
||||
|
||||
- [ ] **Step 4: Create `backend/app/crud/process_category.py`**
|
||||
|
||||
Fields: category_code, category_name
|
||||
Keyword search on: category_code, category_name
|
||||
|
||||
- [ ] **Step 5: Create `backend/app/crud/output_type.py`**
|
||||
|
||||
Fields: type_code, type_name
|
||||
Keyword search on: type_code, type_name
|
||||
|
||||
- [ ] **Step 6: Verify all CRUD modules import correctly**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.crud import consumable, reagent, sop_document, process_category, output_type; print('All CRUD modules OK')"`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/consumable.py backend/app/crud/reagent.py backend/app/crud/sop_document.py backend/app/crud/process_category.py backend/app/crud/output_type.py
|
||||
git commit -m "feat: add batch 1 CRUD modules (consumable, reagent, sop, process-category, output-type)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Backend CRUD — Batch 2 (耗材包 + 工艺库)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/consumable_pack.py`
|
||||
- Create: `backend/app/crud/process.py`
|
||||
|
||||
### 耗材包 CRUD (主表+明细)
|
||||
|
||||
- [ ] **Step 1: Create `backend/app/crud/consumable_pack.py`**
|
||||
|
||||
This module needs:
|
||||
- `BizConsumablePack` main table CRUD (list, get, create, update, soft_delete)
|
||||
- `BizConsumablePackItem` detail table CRUD
|
||||
- **Create**: Accept main fields + list of items. Generate UUID for pack, then create each item with `pack_id`.
|
||||
- **Update**: Accept main fields + list of items. Update main record, then **delete existing items + re-insert** (simple replace strategy).
|
||||
- **Get detail**: Return main record + list of child items (`_row_to_dict` for main includes `items: [...]`)
|
||||
- **Soft delete**: Also soft-delete all child items (set their del_flag=1)
|
||||
- `_row_to_dict` for main includes: pack items as nested list
|
||||
|
||||
`_row_to_dict` for pack item:
|
||||
```python
|
||||
{
|
||||
"id": item.id,
|
||||
"packId": item.pack_id,
|
||||
"consumableCode": item.consumable_code,
|
||||
"consumableName": item.consumable_name,
|
||||
"specModel": item.spec_model,
|
||||
"standardUsage": item.standard_usage,
|
||||
"remark": item.remark or "",
|
||||
}
|
||||
```
|
||||
|
||||
### 工艺库 CRUD (主表+3个子表)
|
||||
|
||||
- [ ] **Step 2: Create `backend/app/crud/process.py`**
|
||||
|
||||
This module needs:
|
||||
- `BizProcess` main table CRUD
|
||||
- `BizProcessReagent` sub-table CRUD
|
||||
- `BizProcessConsumable` sub-table CRUD
|
||||
- `BizProcessOutput` sub-table CRUD
|
||||
- **Create**: Accept main fields + 3 lists (reagents, consumables, outputs). Generate UUID for process, then create each sub-record with `process_id`.
|
||||
- **Update**: Replace strategy — update main, delete+re-insert all sub-records.
|
||||
- **Get detail**: Return main record + 3 nested lists.
|
||||
- **Soft delete**: Also soft-delete all child records.
|
||||
|
||||
`_row_to_dict` for process includes:
|
||||
```python
|
||||
{
|
||||
"id": ...,
|
||||
"processCode": ...,
|
||||
"categoryId": ...,
|
||||
"processName": ...,
|
||||
"description": ...,
|
||||
"equipmentName": ...,
|
||||
"estimatedDuration": ...,
|
||||
"sopIds": ...,
|
||||
# ... public fields ...
|
||||
"reagents": [reagent_row_to_dict(r) for r in reagents],
|
||||
"consumables": [consumable_row_to_dict(c) for c in consumables],
|
||||
"outputs": [output_row_to_dict(o) for o in outputs],
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.crud import consumable_pack, process; print('Batch 2 CRUD OK')"`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/consumable_pack.py backend/app/crud/process.py
|
||||
git commit -m "feat: add batch 2 CRUD modules (consumable-pack, process)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Backend API Routes + Registration
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/production.py`
|
||||
- Modify: `backend/app/api/__init__.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
|
||||
- [ ] **Step 1: Create `backend/app/api/production.py`**
|
||||
|
||||
Follow the exact pattern from `backend/app/api/admin.py` (item-units section).
|
||||
|
||||
Structure:
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any, Optional, List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
|
||||
from app.core.response import success_response, error_response
|
||||
from app.crud import consumable as consumable_svc
|
||||
from app.crud import reagent as reagent_svc
|
||||
from app.crud import sop_document as sop_document_svc
|
||||
from app.crud import process_category as process_category_svc
|
||||
from app.crud import output_type as output_type_svc
|
||||
from app.crud import consumable_pack as consumable_pack_svc
|
||||
from app.crud import process as process_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["生产管理"])
|
||||
|
||||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||||
# Same pattern as admin.py
|
||||
...
|
||||
```
|
||||
|
||||
**Endpoints to create (7 modules × 4 CRUD operations = 28 endpoints):**
|
||||
|
||||
| Module | List (GET) | Create (POST) | Update (PUT) | Delete (DELETE) |
|
||||
|--------|-----------|---------------|--------------|-----------------|
|
||||
| 耗材 | `/consumables` | `/consumables` | `/consumables/{id}` | `/consumables/{id}` |
|
||||
| 试剂 | `/reagents` | `/reagents` | `/reagents/{id}` | `/reagents/{id}` |
|
||||
| SOP | `/sop-documents` | `/sop-documents` | `/sop-documents/{id}` | `/sop-documents/{id}` |
|
||||
| 工序分类 | `/process-categories` | `/process-categories` | `/process-categories/{id}` | `/process-categories/{id}` |
|
||||
| 产出物类型 | `/output-types` | `/output-types` | `/output-types/{id}` | `/output-types/{id}` |
|
||||
| 耗材包 | `/consumable-packs` | `/consumable-packs` | `/consumable-packs/{id}` | `/consumable-packs/{id}` |
|
||||
| 工艺库 | `/processes` | `/processes` | `/processes/{id}` | `/processes/{id}` |
|
||||
|
||||
**Additional list-only endpoints needed by dropdowns:**
|
||||
- GET `/reagers/all` — all reagents (no pagination, for dropdown in process form)
|
||||
- GET `/consumables/all` — all consumables (for dropdown in pack/process form)
|
||||
- GET `/sop-documents/all` — all SOP docs (for multi-select in process form)
|
||||
- GET `/process-categories/all` — all categories (for dropdown)
|
||||
- GET `/output-types/all` — all output types (for dropdown)
|
||||
- GET `/consumable-packs/{id}/items` — pack items (for importing into process)
|
||||
|
||||
**Request body patterns:**
|
||||
|
||||
Simple CRUD (耗材 as example):
|
||||
```python
|
||||
class ConsumableCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
consumable_code: str = Field(..., alias="consumableCode")
|
||||
consumable_name: str = Field(..., alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
description: Optional[str] = None
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
```
|
||||
|
||||
耗材包 Create/Update (includes items):
|
||||
```python
|
||||
class PackItemInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
remark: Optional[str] = None
|
||||
|
||||
class ConsumablePackCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
pack_code: str = Field(..., alias="packCode")
|
||||
pack_name: str = Field(..., alias="packName")
|
||||
usage_scene: Optional[str] = Field(None, alias="usageScene")
|
||||
pack_type: Optional[str] = Field(None, alias="packType")
|
||||
status: Optional[str] = "1"
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
items: List[PackItemInput] = []
|
||||
```
|
||||
|
||||
工艺库 Create/Update (includes 3 sub-lists):
|
||||
```python
|
||||
class ProcessReagentInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
reagent_code: Optional[str] = Field(None, alias="reagentCode")
|
||||
reagent_name: Optional[str] = Field(None, alias="reagentName")
|
||||
spec: Optional[str] = None
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
unit: Optional[str] = None
|
||||
is_key_material: Optional[str] = Field("0", alias="isKeyMaterial")
|
||||
|
||||
class ProcessConsumableInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
consumable_code: Optional[str] = Field(None, alias="consumableCode")
|
||||
consumable_name: Optional[str] = Field(None, alias="consumableName")
|
||||
spec_model: Optional[str] = Field(None, alias="specModel")
|
||||
standard_usage: Optional[str] = Field(None, alias="standardUsage")
|
||||
is_required: Optional[str] = Field("1", alias="isRequired")
|
||||
remark: Optional[str] = None
|
||||
|
||||
class ProcessOutputInput(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
output_type_id: Optional[str] = Field(None, alias="outputTypeId")
|
||||
output_name: Optional[str] = Field(None, alias="outputName")
|
||||
output_barcode: Optional[str] = Field(None, alias="outputBarcode")
|
||||
|
||||
class ProcessCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
process_code: str = Field(..., alias="processCode")
|
||||
category_id: Optional[str] = Field(None, alias="categoryId")
|
||||
process_name: str = Field(..., alias="processName")
|
||||
description: Optional[str] = None
|
||||
equipment_name: Optional[str] = Field(None, alias="equipmentName")
|
||||
estimated_duration: Optional[str] = Field(None, alias="estimatedDuration")
|
||||
sop_ids: Optional[str] = Field(None, alias="sopIds")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
reagents: List[ProcessReagentInput] = []
|
||||
consumables: List[ProcessConsumableInput] = []
|
||||
outputs: List[ProcessOutputInput] = []
|
||||
```
|
||||
|
||||
**Auth patterns:**
|
||||
- List endpoints: `Depends(require_menu_code("production:xxx:list"))`
|
||||
- Write endpoints: `Depends(require_admin_or_super_admin)`
|
||||
- "All" dropdown endpoints: `Depends(require_admin_or_super_admin)` (lightweight, no pagination)
|
||||
|
||||
- [ ] **Step 2: Register router in `backend/app/api/__init__.py`**
|
||||
|
||||
Add:
|
||||
```python
|
||||
from app.api.production import router as production_router
|
||||
```
|
||||
Add `production_router` to `__all__`.
|
||||
|
||||
- [ ] **Step 3: Register router in `backend/app/main.py`**
|
||||
|
||||
Add import and:
|
||||
```python
|
||||
app.include_router(production_router, prefix=settings.API_V1_PREFIX)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify backend starts**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.main import app; routes = [r.path for r in app.routes]; prod = [r for r in routes if 'consumable' in r or 'reagent' in r or 'sop-document' in r or 'process-categor' in r or 'output-type' in r or 'consumable-pack' in r or 'processes' in r]; print(f'Production routes: {len(prod)}'); print(prod[:10])"`
|
||||
|
||||
Expected: 28+ production routes registered.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/production.py backend/app/api/__init__.py backend/app/main.py
|
||||
git commit -m "feat: add production management API routes (7 modules, 28 endpoints)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Frontend API Functions
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/api/production.ts`
|
||||
|
||||
- [ ] **Step 1: Create `web-admin/src/api/production.ts`**
|
||||
|
||||
Follow the pattern from `web-admin/src/api/admin.ts`. Use `request.get/post/put/del` with `params` for JSON body and `data` for FormData.
|
||||
|
||||
For each of the 7 modules, define 4 API functions (list, create, update, delete). Also define "all" functions for dropdown data.
|
||||
|
||||
```typescript
|
||||
import request from '@/utils/http'
|
||||
|
||||
// ==================== 耗材管理 ====================
|
||||
export function fetchConsumableList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get<{ list: any[]; total: number; page: number; pageSize: number; totalPages: number }>({
|
||||
url: '/web/admin/consumables', params
|
||||
})
|
||||
}
|
||||
export function fetchAllConsumables() {
|
||||
return request.get<any[]>({ url: '/web/admin/consumables/all' })
|
||||
}
|
||||
export function fetchCreateConsumable(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/consumables', params: data })
|
||||
}
|
||||
export function fetchUpdateConsumable(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/consumables/${id}`, params: data })
|
||||
}
|
||||
export function fetchDeleteConsumable(id: string) {
|
||||
return request.del({ url: `/web/admin/consumables/${id}` })
|
||||
}
|
||||
|
||||
// ==================== 试剂管理 ====================
|
||||
// (same pattern: fetchReagentList, fetchAllReagents, fetchCreateReagent, fetchUpdateReagent, fetchDeleteReagent)
|
||||
|
||||
// ==================== SOP文档 ====================
|
||||
// (same pattern: fetchSopList, fetchAllSopDocuments, fetchCreateSop, fetchUpdateSop, fetchDeleteSop)
|
||||
|
||||
// ==================== 工艺工序分类 ====================
|
||||
// (same pattern: fetchProcessCategoryList, fetchAllProcessCategories, fetchCreateProcessCategory, ...)
|
||||
|
||||
// ==================== 工艺产出物类型 ====================
|
||||
// (same pattern: fetchOutputTypeList, fetchAllOutputTypes, fetchCreateOutputType, ...)
|
||||
|
||||
// ==================== 耗材包管理 ====================
|
||||
// (same pattern, but create/update body includes items array)
|
||||
|
||||
// ==================== 工艺库管理 ====================
|
||||
// (same pattern, but create/update body includes reagents, consumables, outputs arrays)
|
||||
```
|
||||
|
||||
**Important convention from codebase:** POST/PUT use `params` for JSON body, `showSuccessMessage: true` is NOT needed (messages shown in page component).
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/production.ts
|
||||
git commit -m "feat: add production management frontend API functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend Routes + Menu
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||||
|
||||
- [ ] **Step 1: Add production route group to sqxp.ts**
|
||||
|
||||
Add a new top-level route entry. Use a unique ID (e.g., `'90000000000000000000000000000001'`). Follow the existing pattern of other top-level groups.
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: '90000000000000000000000000000001',
|
||||
name: 'ProductionManagement',
|
||||
path: '/production',
|
||||
component: '/index/index',
|
||||
meta: { title: '生产管理', icon: 'ri:factory-line', keepAlive: false, roles: ['admin'] },
|
||||
children: [
|
||||
{
|
||||
id: '90000000000000000000000000000010',
|
||||
name: 'ProductionConsumable',
|
||||
path: 'consumable',
|
||||
component: '/production/consumable/index',
|
||||
meta: { title: '耗材管理', keepAlive: true, menuCode: 'production:consumable:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000020',
|
||||
name: 'ProductionReagent',
|
||||
path: 'reagent',
|
||||
component: '/production/reagent/index',
|
||||
meta: { title: '试剂管理', keepAlive: true, menuCode: 'production:reagent:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000030',
|
||||
name: 'ProductionSop',
|
||||
path: 'sop',
|
||||
component: '/production/sop/index',
|
||||
meta: { title: 'SOP文档', keepAlive: true, menuCode: 'production:sop:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000040',
|
||||
name: 'ProductionProcessCategory',
|
||||
path: 'process-category',
|
||||
component: '/production/process-category/index',
|
||||
meta: { title: '工艺工序分类', keepAlive: true, menuCode: 'production:process-category:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000050',
|
||||
name: 'ProductionOutputType',
|
||||
path: 'output-type',
|
||||
component: '/production/output-type/index',
|
||||
meta: { title: '工艺产出物类型', keepAlive: true, menuCode: 'production:output-type:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000060',
|
||||
name: 'ProductionConsumablePack',
|
||||
path: 'consumable-pack',
|
||||
component: '/production/consumable-pack/index',
|
||||
meta: { title: '耗材包管理', keepAlive: true, menuCode: 'production:consumable-pack:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000070',
|
||||
name: 'ProductionProcess',
|
||||
path: 'process',
|
||||
component: '/production/process/index',
|
||||
meta: { title: '工艺库管理', keepAlive: true, menuCode: 'production:process:list' }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: Add this to `sqxpAdminRoutes` array in `sqxp.ts`. Icon only on top-level (sidebar convention).
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/router/modules/sqxp.ts
|
||||
git commit -m "feat: add production management frontend routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Frontend Pages — Batch 1 (5 simple CRUD pages)
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/production/consumable/index.vue`
|
||||
- Create: `web-admin/src/views/production/reagent/index.vue`
|
||||
- Create: `web-admin/src/views/production/sop/index.vue`
|
||||
- Create: `web-admin/src/views/production/process-category/index.vue`
|
||||
- Create: `web-admin/src/views/production/output-type/index.vue`
|
||||
|
||||
All 5 pages follow the **exact same pattern** as `web-admin/src/views/system/unit/index.vue` (327 lines). Read that file first and replicate its structure.
|
||||
|
||||
### Common page structure:
|
||||
```
|
||||
page-container
|
||||
page-head (h1 title + description)
|
||||
panel
|
||||
panel-top (title-bar-heading with green vertical bar)
|
||||
filter-block (keyword search + search/reset buttons)
|
||||
list-toolbar (selected count + add button)
|
||||
list-body/list-wrap (el-table with columns + pagination)
|
||||
el-dialog (add/edit form, label-position="top", form-grid-2)
|
||||
```
|
||||
|
||||
### Per-page differences:
|
||||
|
||||
- [ ] **Step 1: Create 耗材管理 page (`production/consumable/index.vue`)**
|
||||
|
||||
`defineOptions({ name: 'ProductionConsumable' })`
|
||||
- Table columns: 耗材编码, 耗材名称, 规格型号, 标准用量, 描述, 创建时间, 操作
|
||||
- Search keyword searches on: consumableCode, consumableName
|
||||
- Form fields: consumableCode (required), consumableName (required), specModel, standardUsage, description, remark
|
||||
- API: `fetchConsumableList, fetchCreateConsumable, fetchUpdateConsumable, fetchDeleteConsumable` from `@/api/production`
|
||||
|
||||
- [ ] **Step 2: Create 试剂管理 page (`production/reagent/index.vue`)**
|
||||
|
||||
`defineOptions({ name: 'ProductionReagent' })`
|
||||
- Table columns: 试剂编码, 试剂名称, 规格, 标准用量, 单位, 描述, 创建时间, 操作
|
||||
- Form fields: reagentCode (required), reagentName (required), spec, standardUsage, unit (el-select dropdown from item-unit API), description, remark
|
||||
- **Special**: "单位" field is an el-select that loads options from `fetchAllItemUnits()` (import from `@/api/admin`)
|
||||
- API: `fetchReagentList, fetchCreateReagent, fetchUpdateReagent, fetchDeleteReagent` from `@/api/production`
|
||||
|
||||
- [ ] **Step 3: Create SOP文档 page (`production/sop/index.vue`)**
|
||||
|
||||
`defineOptions({ name: 'ProductionSop' })`
|
||||
- Table columns: SOP编号, SOP文档名称, 版本号, 附件数, 创建时间, 操作
|
||||
- Form fields: sopCode (required), sopName (required), versionNo, sopAttachments (file upload card)
|
||||
- **Special**: File upload using the card layout pattern from `ProtocolFormDialog.vue`:
|
||||
- Use `el-upload` with `:auto-upload="false"`, `@change="handleFileChange"`
|
||||
- Upload via `fetchUploadAttachment(formData)` from `@/api/business`
|
||||
- Store comma-separated URLs in `sopAttachments` field
|
||||
- Display attachment count in table
|
||||
- API: `fetchSopList, fetchCreateSop, fetchUpdateSop, fetchDeleteSop` from `@/api/production`
|
||||
|
||||
- [ ] **Step 4: Create 工艺工序分类 page (`production/process-category/index.vue`)**
|
||||
|
||||
`defineOptions({ name: 'ProductionProcessCategory' })`
|
||||
- Table columns: 分类编码, 分类名称, 创建时间, 操作
|
||||
- Form fields: categoryCode (required), categoryName (required), remark
|
||||
- Simplest page — identical to unit template
|
||||
- API: `fetchProcessCategoryList, fetchCreateProcessCategory, fetchUpdateProcessCategory, fetchDeleteProcessCategory` from `@/api/production`
|
||||
|
||||
- [ ] **Step 5: Create 工艺产出物类型 page (`production/output-type/index.vue`)**
|
||||
|
||||
`defineOptions({ name: 'ProductionOutputType' })`
|
||||
- Table columns: 类型编码, 类型名称, 创建时间, 操作
|
||||
- Form fields: typeCode (required), typeName (required), remark
|
||||
- Same pattern as 工序分类 — very simple
|
||||
- API: `fetchOutputTypeList, fetchCreateOutputType, fetchUpdateOutputType, fetchDeleteOutputType` from `@/api/production`
|
||||
|
||||
- [ ] **Step 6: Verify all 5 pages load**
|
||||
|
||||
Run: `cd web-admin && pnpm build 2>&1 | tail -5`
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/production/
|
||||
git commit -m "feat: add batch 1 frontend pages (consumable, reagent, sop, process-category, output-type)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Frontend Page — 耗材包管理
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/production/consumable-pack/index.vue`
|
||||
|
||||
- [ ] **Step 1: Create 耗材包管理 page**
|
||||
|
||||
`defineOptions({ name: 'ProductionConsumablePack' })`
|
||||
|
||||
**List page**: Standard template with additional columns:
|
||||
- Table columns: 编号, 名称, 使用场景, 类型(display dict: bzhcb=标准耗材包, zdyhcb=自定义耗材包), 状态(el-switch or tag: 1=启用, 0=停用), 创建时间, 操作
|
||||
|
||||
**Add/Edit dialog**: Larger dialog with 2 sections:
|
||||
1. **基本信息**: packCode, packName, usageScene, packType (el-radio-group: bzhcb/zdyhcb), status (el-switch), remark
|
||||
2. **耗材明细**: Embedded el-table inside the dialog for managing pack items:
|
||||
- Each row: consumableCode, consumableName, specModel, standardUsage, remark
|
||||
- "添加耗材" button opens a sub-dialog to select from consumable list (use `fetchAllConsumables()` dropdown or table select)
|
||||
- After selecting, auto-fill code/name/spec from the consumable record
|
||||
- Row-level delete button
|
||||
- Inline editing for standardUsage
|
||||
|
||||
**Data flow**:
|
||||
- On dialog open (edit mode): Load pack detail which includes `items` array
|
||||
- On save: Submit main fields + items array together to create/update API
|
||||
|
||||
- [ ] **Step 2: Verify page loads**
|
||||
|
||||
Run: `cd web-admin && pnpm build 2>&1 | tail -5`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/production/consumable-pack/
|
||||
git commit -m "feat: add consumable pack management frontend page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Frontend Page — 工艺库管理
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/production/process/index.vue`
|
||||
|
||||
- [ ] **Step 1: Create 工艺库管理 page**
|
||||
|
||||
`defineOptions({ name: 'ProductionProcess' })`
|
||||
|
||||
**List page**: Standard template:
|
||||
- Table columns: 工序代码, 工序名称, 工序分类(resolve from category map), 描述, 设备名称, 预估耗时, 创建时间, 操作
|
||||
- On load: also fetch all categories, SOP docs for display resolution
|
||||
|
||||
**Add/Edit dialog**: Wide dialog (width="900px") with `el-tabs`:
|
||||
- **Tab 1 — 基本信息**: processCode, categoryId (el-select from fetchAllProcessCategories), processName, description, equipmentName, estimatedDuration, sopIds (el-select multiple from fetchAllSopDocuments), remark
|
||||
- **Tab 2 — 试剂关联**: el-table with rows:
|
||||
- Columns: reagentCode, reagentName, spec, standardUsage, unit, isKeyMaterial (el-switch/checkbox)
|
||||
- "添加试剂" button → select from reagent dropdown (fetchAllReagents), auto-fill code/name/spec/usage/unit
|
||||
- User can modify standardUsage and isKeyMaterial
|
||||
- Row delete button
|
||||
- **Tab 3 — 耗材关联**: el-table with rows:
|
||||
- Columns: consumableCode, consumableName, specModel, standardUsage, isRequired (el-switch), remark
|
||||
- Two add modes:
|
||||
1. "添加耗材" button → select from consumable dropdown
|
||||
2. "从耗材包导入" button → select pack (el-select from fetchAllConsumablePacks), then load pack items and merge into table
|
||||
- Row delete button
|
||||
- **Tab 4 — 产出物**: el-table with rows:
|
||||
- Columns: outputTypeId (display resolved name), outputName, outputBarcode
|
||||
- "添加产出物" button → row with el-select for output type + input for name
|
||||
- Row delete button
|
||||
|
||||
**Data flow**:
|
||||
- On dialog open (edit mode): Load process detail which includes reagents/consumables/outputs arrays
|
||||
- On tab switch: No special action needed (all data in local reactive state)
|
||||
- On save: Collect all 4 sections' data and submit together
|
||||
|
||||
**Key considerations**:
|
||||
- Load dropdown data (categories, reagents, consumables, SOP docs, output types, consumable packs) when dialog opens
|
||||
- Use `onMounted` or dialog open handler to pre-fetch reference data
|
||||
- Display category name in table by maintaining a category map `{id: name}`
|
||||
|
||||
- [ ] **Step 2: Verify page loads**
|
||||
|
||||
Run: `cd web-admin && pnpm build 2>&1 | tail -5`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/production/process/
|
||||
git commit -m "feat: add process library management frontend page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Final Verification
|
||||
|
||||
- [ ] **Step 1: Full frontend build**
|
||||
|
||||
Run: `cd web-admin && pnpm build`
|
||||
Expected: Exit code 0, no errors.
|
||||
|
||||
- [ ] **Step 2: Backend import check**
|
||||
|
||||
Run: `cd backend && source venv/bin/activate && python -c "from app.main import app; print('Backend OK')"`
|
||||
|
||||
- [ ] **Step 3: Manual smoke test (if dev server available)**
|
||||
|
||||
1. Start backend: `cd backend && ./start-local.sh`
|
||||
2. Start frontend: `cd web-admin && pnpm dev`
|
||||
3. Navigate to each of the 7 pages under 生产管理 sidebar
|
||||
4. Test CRUD on each page (add, edit, delete, search)
|
||||
5. Test dropdown loading on 试剂 (unit dropdown), 工艺库 (category/reagent/consumable/SOP/output dropdowns)
|
||||
6. Test file upload on SOP文档
|
||||
7. Test耗材包 item management (add/remove items)
|
||||
8. Test工艺库 tab management (add/remove reagents, consumables, outputs)
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Task 1 (SQL)
|
||||
↓
|
||||
Task 2 (Models)
|
||||
↓
|
||||
Task 3 (CRUD Batch 1) ← independent from Task 6/7
|
||||
↓
|
||||
Task 4 (CRUD Batch 2)
|
||||
↓
|
||||
Task 5 (API Routes) ← depends on Task 3 + 4
|
||||
↓
|
||||
Task 6 (Frontend API) ← independent, can parallel with Task 5
|
||||
Task 7 (Frontend Routes) ← independent, can parallel with Task 5
|
||||
↓
|
||||
Task 8 (Frontend Batch 1) ← depends on Task 5 + 6 + 7
|
||||
↓
|
||||
Task 9 (Frontend 耗材包) ← depends on Task 5 + 6 + 7
|
||||
Task 10 (Frontend 工艺库) ← depends on Task 5 + 6 + 7 + 8 (needs dropdowns from batch 1)
|
||||
↓
|
||||
Task 11 (Final Verification) ← depends on all above
|
||||
```
|
||||
|
||||
**Parallelization opportunities:**
|
||||
- Tasks 6 + 7 can run in parallel with Task 5
|
||||
- Tasks 8 + 9 can run in parallel (both depend on Task 5+6+7)
|
||||
- Task 10 should wait for Task 8 (needs dropdown APIs working)
|
||||
@@ -0,0 +1,993 @@
|
||||
# 科研捐赠管理模块 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:** 实现科研捐赠管理模块——独立的新业务模块,支持协议 CRUD、审核、状态流转、与细胞存储的双向转化。
|
||||
|
||||
**Architecture:** 方案 C — 独立数据库表(6 张)+ 独立后端 API/CRUD + 独立前端页面,复用基础数据表和提取公共前端组件。不侵入现有细胞存储代码。
|
||||
|
||||
**Tech Stack:** MySQL 5.7 · SQLAlchemy · Pydantic v2 · FastAPI · Vue 3 · Element Plus · Tailwind CSS 4 · TypeScript
|
||||
|
||||
**Design Spec:** `docs/superpowers/specs/2026-05-17-research-donation-module-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New Files (Backend)
|
||||
- `docs/sql/research_donation_tables.sql` — 6 张新建表 DDL
|
||||
- `backend/app/models/research_donation.py` — 6 个 SQLAlchemy Model(独立文件避免 models.py 过大)
|
||||
- `backend/app/schemas/research_donation.py` — Pydantic schemas(注意:细胞存储没有独立 schema 文件,但科研捐赠新建一个更清晰)
|
||||
- `backend/app/crud/research_donation.py` — CRUD/业务逻辑
|
||||
- `backend/app/api/research_donation.py` — API 路由
|
||||
|
||||
### Modified Files (Backend)
|
||||
- `backend/app/models/__init__.py` — import 新 models
|
||||
- `backend/app/api/__init__.py` — 注册新路由
|
||||
- `backend/app/main.py` — include 新路由
|
||||
|
||||
### New Files (Frontend)
|
||||
- `web-admin/src/api/research-donation.ts` — API 模块
|
||||
- `web-admin/src/views/research-donation/list/index.vue` — 捐赠协议列表页
|
||||
- `web-admin/src/views/research-donation/list/DonationFormDialog.vue` — 新增/编辑弹窗
|
||||
- `web-admin/src/views/research-donation/list/DonationViewDialog.vue` — 查看详情弹窗
|
||||
- `web-admin/src/views/research-donation/list/DonationAuditDialog.vue` — 审核弹窗
|
||||
- `web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue` — 状态变更弹窗
|
||||
- `web-admin/src/views/research-donation/audit/index.vue` — 审核管理页
|
||||
|
||||
### Modified Files (Frontend)
|
||||
- `web-admin/src/router/modules/sqxp.ts` — 新增科研捐赠菜单路由
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue` — 增加「转科研」操作
|
||||
|
||||
### Reference Files (DO NOT MODIFY — 用于参照模式)
|
||||
- `backend/app/models/models.py:707-937` — 细胞存储 Model 定义
|
||||
- `backend/app/api/collection_protocol.py` — 细胞存储 API 路由模式
|
||||
- `backend/app/crud/collection_protocol.py` — 细胞存储 CRUD 模式
|
||||
- `web-admin/src/views/cell-storage/protocol/index.vue` — 协议列表页模式
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` — 表单弹窗模式
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` — 查看弹窗模式
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolAuditDialog.vue` — 审核弹窗模式
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue` — 状态变更弹窗模式
|
||||
- `web-admin/src/views/cell-storage/protocol-audit/index.vue` — 审核管理页模式
|
||||
- `web-admin/src/api/collection-protocol.ts` — API 模块模式
|
||||
- `web-admin/src/router/modules/sqxp.ts:204-252` — 细胞存储路由注册
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SQL 建表脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/research_donation_tables.sql`
|
||||
|
||||
**Reference:** `docs/sql/qc_item_param_and_task_result.sql`(SQL 格式参考),`backend/app/models/models.py:707-937`(字段定义参考)
|
||||
|
||||
- [ ] **Step 1: 编写 6 张表的 DDL**
|
||||
|
||||
创建 `docs/sql/research_donation_tables.sql`,包含以下 6 张表的 `CREATE TABLE IF NOT EXISTS` 语句:
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 科研捐赠管理模块 — 建表脚本
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 科研捐赠主表
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`donation_no` varchar(64) NOT NULL COMMENT '捐赠协议编号',
|
||||
`donation_status` varchar(16) NOT NULL DEFAULT 'dsx' COMMENT '状态 dsx待生效 sxz生效中 ygq已过期 yzf已作废 zckc转细胞存储',
|
||||
`donation_date` char(8) DEFAULT NULL COMMENT '捐赠日期YYYYMMDD',
|
||||
`source_type` varchar(20) NOT NULL DEFAULT 'direct' COMMENT '来源类型 direct独立新建 from_storage从细胞存储转来',
|
||||
`source_protocol_id` varchar(64) DEFAULT NULL COMMENT '来源协议ID(from_storage时填源细胞存储协议ID)',
|
||||
`consent_form_urls` text DEFAULT NULL COMMENT '知情同意书附件URL(逗号分隔,必填)',
|
||||
`attachment_urls` text DEFAULT NULL COMMENT '其他附件URL(逗号分隔)',
|
||||
`collector_user_id` varchar(64) DEFAULT NULL COMMENT '采集专员用户ID',
|
||||
`effective_time` datetime DEFAULT NULL COMMENT '生效时间',
|
||||
`effective_desc` varchar(512) DEFAULT NULL COMMENT '生效描述',
|
||||
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
|
||||
`expire_desc` varchar(512) DEFAULT NULL COMMENT '过期描述',
|
||||
`void_time` datetime DEFAULT NULL COMMENT '作废时间',
|
||||
`void_desc` varchar(512) DEFAULT NULL COMMENT '作废描述',
|
||||
`to_storage_time` datetime DEFAULT NULL COMMENT '转存储时间',
|
||||
`to_storage_desc` varchar(512) DEFAULT NULL COMMENT '转存储描述',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注信息',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rd_tenant_status` (`tenant_no`, `donation_status`, `del_flag`),
|
||||
KEY `idx_rd_collector` (`collector_user_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠主表';
|
||||
|
||||
-- 2. 科研捐赠审核记录
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_audit` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠ID',
|
||||
`auditor_user_id` varchar(64) DEFAULT NULL COMMENT '审核人',
|
||||
`audit_remark` varchar(1000) DEFAULT NULL COMMENT '审核备注',
|
||||
`audit_status` varchar(16) NOT NULL COMMENT '审核状态 shtg审核通过 shth审核驳回',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rda_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠审核记录';
|
||||
|
||||
-- 3. 科研捐赠当事人信息
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_party` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠ID',
|
||||
`customer_id` varchar(64) DEFAULT NULL COMMENT '客户ID',
|
||||
`mother_name` varchar(100) DEFAULT NULL COMMENT '母亲姓名',
|
||||
`mother_phone` varchar(32) DEFAULT NULL COMMENT '母亲电话',
|
||||
`mother_id_card` varchar(32) DEFAULT NULL COMMENT '母亲身份证号',
|
||||
`mother_birth_ym` char(6) DEFAULT NULL COMMENT '母亲出生年月YYYYMM',
|
||||
`mother_ethnic` varchar(32) DEFAULT NULL COMMENT '母亲民族',
|
||||
`mother_address` varchar(512) DEFAULT NULL COMMENT '母亲地址',
|
||||
`father_name` varchar(100) DEFAULT NULL COMMENT '父亲姓名',
|
||||
`father_phone` varchar(32) DEFAULT NULL COMMENT '父亲电话',
|
||||
`father_id_card` varchar(32) DEFAULT NULL COMMENT '父亲身份证号',
|
||||
`newborn_name` varchar(100) DEFAULT NULL COMMENT '新生儿姓名',
|
||||
`newborn_id_card` varchar(32) DEFAULT NULL COMMENT '新生儿身份证号',
|
||||
`newborn_delivery_time` datetime DEFAULT NULL COMMENT '新生儿分娩时间',
|
||||
`newborn_sex` char(1) DEFAULT NULL COMMENT '新生儿性别',
|
||||
`delivery_mode` varchar(16) DEFAULT NULL COMMENT '分娩方式',
|
||||
`birth_order` int(11) DEFAULT NULL COMMENT '出生顺序',
|
||||
`newborn_weight_gram` int(11) DEFAULT NULL COMMENT '新生儿体重(克)',
|
||||
`apgar_1min` decimal(4,1) DEFAULT NULL COMMENT 'Apgar 1分钟评分',
|
||||
`apgar_5min` decimal(4,1) DEFAULT NULL COMMENT 'Apgar 5分钟评分',
|
||||
`collector_user_id` varchar(64) DEFAULT NULL COMMENT '采集专员用户ID',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rdp_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠当事人信息';
|
||||
|
||||
-- 4. 科研捐赠采集信息
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_collection` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠ID',
|
||||
`region_id` varchar(64) DEFAULT NULL COMMENT '区域ID',
|
||||
`hospital_id` varchar(64) DEFAULT NULL COMMENT '医院ID',
|
||||
`medical_staff` varchar(200) DEFAULT NULL COMMENT '医护人员',
|
||||
`sales_user_id` varchar(64) DEFAULT NULL COMMENT '销售人员用户ID',
|
||||
`sample_collect_time` datetime DEFAULT NULL COMMENT '样本采集时间',
|
||||
`maternal_blood_collect_time` datetime DEFAULT NULL COMMENT '母血采集时间',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rdc_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠采集信息';
|
||||
|
||||
-- 5. 科研捐赠样本信息(无金额字段)
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_sample` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠ID',
|
||||
`sample_type_id` varchar(64) DEFAULT NULL COMMENT '样本类型ID',
|
||||
`sample_code_id` varchar(64) DEFAULT NULL COMMENT '样本编码ID',
|
||||
`sample_code` varchar(32) DEFAULT NULL COMMENT '样本编码',
|
||||
`collect_amount` decimal(18,4) DEFAULT NULL COMMENT '采集量',
|
||||
`unit_id` varchar(64) DEFAULT NULL COMMENT '单位ID',
|
||||
`storage_years` int(11) DEFAULT NULL COMMENT '存储年限',
|
||||
`sample_status` varchar(16) DEFAULT NULL COMMENT '样本状态',
|
||||
`storage_in_date` char(8) DEFAULT NULL COMMENT '入库日期YYYYMMDD',
|
||||
`detect_start_date` char(8) DEFAULT NULL COMMENT '检测开始日期YYYYMMDD',
|
||||
`void_date` char(8) DEFAULT NULL COMMENT '作废日期YYYYMMDD',
|
||||
`storage_expire_date` char(8) DEFAULT NULL COMMENT '存储到期日期YYYYMMDD',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rds_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠样本信息';
|
||||
|
||||
-- 6. 科研捐赠样本运输
|
||||
CREATE TABLE IF NOT EXISTS `biz_research_donation_sample_transport` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`research_donation_id` varchar(64) NOT NULL COMMENT '科研捐赠ID',
|
||||
`sample_id` varchar(64) NOT NULL COMMENT '样本ID',
|
||||
`transport_type` varchar(16) NOT NULL COMMENT 'kdys快递运输 ryxd人员携带',
|
||||
`express_company_id` varchar(64) DEFAULT NULL COMMENT '快递公司ID',
|
||||
`waybill_no` varchar(128) DEFAULT NULL COMMENT '快递单号',
|
||||
`dest_site_id` varchar(64) DEFAULT NULL COMMENT '目的站点ID',
|
||||
`express_fee` decimal(18,2) DEFAULT NULL COMMENT '快递费用(元)',
|
||||
`eta_time` datetime DEFAULT NULL COMMENT '预计到达时间',
|
||||
`carrier_name` varchar(100) DEFAULT NULL COMMENT '携带人员姓名',
|
||||
`carrier_phone` varchar(32) DEFAULT NULL COMMENT '携带人员联系电话',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
`del_flag` tinyint(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_rdst_donation` (`research_donation_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科研捐赠样本运输';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add docs/sql/research_donation_tables.sql
|
||||
git commit -m "feat: 科研捐赠管理模块建表脚本"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 Model 定义
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/research_donation.py`
|
||||
- Modify: `backend/app/models/__init__.py`
|
||||
|
||||
**Reference:** `backend/app/models/models.py:707-937`(细胞存储 Model 定义模式)
|
||||
|
||||
- [ ] **Step 1: 创建 research_donation.py Model 文件**
|
||||
|
||||
创建 `backend/app/models/research_donation.py`,包含 6 个 Model 类。模式完全对齐 `models.py` 中的细胞存储 Model(Column 定义风格、公共字段、default 值):
|
||||
|
||||
```python
|
||||
"""科研捐赠管理 — 数据模型"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, Numeric, SmallInteger, String
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class BizResearchDonation(Base):
|
||||
"""科研捐赠主表 biz_research_donation"""
|
||||
|
||||
__tablename__ = "biz_research_donation"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
donation_no = Column(String(64), nullable=False, comment="捐赠协议编号")
|
||||
donation_status = Column(String(16), nullable=False, default="dsx", comment="状态 dsx/sxz/ygq/yzf/zckc")
|
||||
donation_date = Column(String(8), nullable=True, comment="捐赠日期YYYYMMDD")
|
||||
source_type = Column(String(20), nullable=False, default="direct", comment="来源类型 direct/from_storage")
|
||||
source_protocol_id = Column(String(64), nullable=True, comment="来源协议ID")
|
||||
consent_form_urls = Column(String(4000), nullable=True, comment="知情同意书附件URL(逗号分隔)")
|
||||
attachment_urls = Column(String(4000), nullable=True, comment="其他附件URL(逗号分隔)")
|
||||
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
|
||||
effective_time = Column(DateTime, nullable=True, comment="生效时间")
|
||||
effective_desc = Column(String(512), nullable=True, comment="生效描述")
|
||||
expire_time = Column(DateTime, nullable=True, comment="过期时间")
|
||||
expire_desc = Column(String(512), nullable=True, comment="过期描述")
|
||||
void_time = Column(DateTime, nullable=True, comment="作废时间")
|
||||
void_desc = Column(String(512), nullable=True, comment="作废描述")
|
||||
to_storage_time = Column(DateTime, nullable=True, comment="转存储时间")
|
||||
to_storage_desc = Column(String(512), nullable=True, comment="转存储描述")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注信息")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationAudit(Base):
|
||||
"""科研捐赠审核记录 biz_research_donation_audit"""
|
||||
|
||||
__tablename__ = "biz_research_donation_audit"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
auditor_user_id = Column(String(64), nullable=True, comment="审核人")
|
||||
audit_remark = Column(String(1000), nullable=True, comment="审核备注")
|
||||
audit_status = Column(String(16), nullable=False, comment="审核状态 shtg/shth")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationParty(Base):
|
||||
"""科研捐赠当事人信息 biz_research_donation_party"""
|
||||
|
||||
__tablename__ = "biz_research_donation_party"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
customer_id = Column(String(64), nullable=True, comment="客户ID")
|
||||
mother_name = Column(String(100), nullable=True, comment="母亲姓名")
|
||||
mother_phone = Column(String(32), nullable=True, comment="母亲电话")
|
||||
mother_id_card = Column(String(32), nullable=True, comment="母亲身份证号")
|
||||
mother_birth_ym = Column(String(6), nullable=True, comment="母亲出生年月YYYYMM")
|
||||
mother_ethnic = Column(String(32), nullable=True, comment="母亲民族")
|
||||
mother_address = Column(String(512), nullable=True, comment="母亲地址")
|
||||
father_name = Column(String(100), nullable=True, comment="父亲姓名")
|
||||
father_phone = Column(String(32), nullable=True, comment="父亲电话")
|
||||
father_id_card = Column(String(32), nullable=True, comment="父亲身份证号")
|
||||
newborn_name = Column(String(100), nullable=True, comment="新生儿姓名")
|
||||
newborn_id_card = Column(String(32), nullable=True, comment="新生儿身份证号")
|
||||
newborn_delivery_time = Column(DateTime, nullable=True, comment="新生儿分娩时间")
|
||||
newborn_sex = Column(String(1), nullable=True, comment="新生儿性别")
|
||||
delivery_mode = Column(String(16), nullable=True, comment="分娩方式")
|
||||
birth_order = Column(Integer, nullable=True, comment="出生顺序")
|
||||
newborn_weight_gram = Column(Integer, nullable=True, comment="新生儿体重(克)")
|
||||
apgar_1min = Column(Numeric(4, 1), nullable=True, comment="Apgar 1分钟评分")
|
||||
apgar_5min = Column(Numeric(4, 1), nullable=True, comment="Apgar 5分钟评分")
|
||||
collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationCollection(Base):
|
||||
"""科研捐赠采集信息 biz_research_donation_collection"""
|
||||
|
||||
__tablename__ = "biz_research_donation_collection"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
region_id = Column(String(64), nullable=True, comment="区域ID")
|
||||
hospital_id = Column(String(64), nullable=True, comment="医院ID")
|
||||
medical_staff = Column(String(200), nullable=True, comment="医护人员")
|
||||
sales_user_id = Column(String(64), nullable=True, comment="销售人员用户ID")
|
||||
sample_collect_time = Column(DateTime, nullable=True, comment="样本采集时间")
|
||||
maternal_blood_collect_time = Column(DateTime, nullable=True, comment="母血采集时间")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationSample(Base):
|
||||
"""科研捐赠样本信息 biz_research_donation_sample(无金额字段)"""
|
||||
|
||||
__tablename__ = "biz_research_donation_sample"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
sample_type_id = Column(String(64), nullable=True, comment="样本类型ID")
|
||||
sample_code_id = Column(String(64), nullable=True, comment="样本编码ID")
|
||||
sample_code = Column(String(32), nullable=True, comment="样本编码")
|
||||
collect_amount = Column(Numeric(18, 4), nullable=True, comment="采集量")
|
||||
unit_id = Column(String(64), nullable=True, comment="单位ID")
|
||||
storage_years = Column(Integer, nullable=True, comment="存储年限")
|
||||
sample_status = Column(String(16), nullable=True, comment="样本状态")
|
||||
storage_in_date = Column(String(8), nullable=True, comment="入库日期YYYYMMDD")
|
||||
detect_start_date = Column(String(8), nullable=True, comment="检测开始日期YYYYMMDD")
|
||||
void_date = Column(String(8), nullable=True, comment="作废日期YYYYMMDD")
|
||||
storage_expire_date = Column(String(8), nullable=True, comment="存储到期日期YYYYMMDD")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizResearchDonationSampleTransport(Base):
|
||||
"""科研捐赠样本运输 biz_research_donation_sample_transport"""
|
||||
|
||||
__tablename__ = "biz_research_donation_sample_transport"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
research_donation_id = Column(String(64), nullable=False, comment="科研捐赠ID")
|
||||
sample_id = Column(String(64), nullable=False, comment="样本ID")
|
||||
transport_type = Column(String(16), nullable=False, comment="kdys快递运输 ryxd人员携带")
|
||||
express_company_id = Column(String(64), nullable=True, comment="快递公司ID")
|
||||
waybill_no = Column(String(128), nullable=True, comment="快递单号")
|
||||
dest_site_id = Column(String(64), nullable=True, comment="目的站点ID")
|
||||
express_fee = Column(Numeric(18, 2), nullable=True, comment="快递费用(元)")
|
||||
eta_time = Column(DateTime, nullable=True, comment="预计到达时间")
|
||||
carrier_name = Column(String(100), nullable=True, comment="携带人员姓名")
|
||||
carrier_phone = Column(String(32), nullable=True, comment="携带人员联系电话")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 models/__init__.py 中注册**
|
||||
|
||||
读取 `backend/app/models/__init__.py`,在 import 区增加:
|
||||
|
||||
```python
|
||||
from app.models.research_donation import ( # noqa: F401
|
||||
BizResearchDonation,
|
||||
BizResearchDonationAudit,
|
||||
BizResearchDonationParty,
|
||||
BizResearchDonationCollection,
|
||||
BizResearchDonationSample,
|
||||
BizResearchDonationSampleTransport,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/research_donation.py backend/app/models/__init__.py
|
||||
git commit -m "feat: 科研捐赠管理模块 — SQLAlchemy Model 定义"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 CRUD 层
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/research_donation.py`
|
||||
|
||||
**Reference:** `backend/app/crud/collection_protocol.py`(CRUD 模式参考,但大幅简化:无缴费逻辑)
|
||||
|
||||
这是后端最核心的文件,包含以下功能模块:
|
||||
|
||||
### 功能清单
|
||||
|
||||
1. **列表查询** `list_donations(db, tenant_no, page, page_size, keyword, donation_status, collector_user_id)` — 分页、关键字搜索(编号/母亲姓名)、状态过滤、数据权限
|
||||
2. **详情查询** `get_donation_detail(db, donation_id)` — 主表 + 所有子资源(当事人、采集、样本+运输)
|
||||
3. **创建** `create_donation(db, data, tenant_no, user_id)` — 主表 + 子资源整体创建
|
||||
4. **更新** `update_donation(db, donation_id, data, tenant_no, user_id)` — 子资源 upsert(先删后插)
|
||||
5. **删除** `delete_donation(db, donation_id, tenant_no)` — 软删除主表 + 所有子资源
|
||||
6. **批量删除** `batch_delete_donations(db, ids, tenant_no)`
|
||||
7. **审核列表** `list_audits(db, tenant_no, page, page_size, keyword, audit_status)`
|
||||
8. **提交审核** `submit_audit(db, donation_id, audit_status, audit_remark, user_id, tenant_no)`
|
||||
9. **状态变更** `change_status(db, donation_id, new_status, desc, user_id, tenant_no)` — 含状态机校验
|
||||
10. **从细胞存储转化** `convert_from_storage(db, protocol_id, tenant_no, user_id)` — 读源协议 → 创建捐赠记录
|
||||
11. **转为细胞存储** `convert_to_storage(db, donation_id, tenant_no, user_id)` — 读捐赠 → 创建协议记录
|
||||
|
||||
### 关键模式(参照 collection_protocol.py)
|
||||
|
||||
- UUID 生成:`import uuid; uuid.uuid4().hex`
|
||||
- camelCase → snake_case 转换:参照 `_normalize_fields()` 模式
|
||||
- 附件处理:`urls_to_attachment_list()` 复用
|
||||
- 子资源 upsert:先删除旧数据 `del_flag=1`,再批量 insert 新数据
|
||||
- 租户隔离:所有查询加 `tenant_no` 过滤
|
||||
- 软删除:设置 `del_flag=1`,查询默认 `del_flag=0`
|
||||
|
||||
### 状态机
|
||||
|
||||
```
|
||||
合法转换:
|
||||
dsx → sxz(生效)
|
||||
dsx → yzf(作废)
|
||||
sxz → ygq(过期)
|
||||
sxz → yzf(作废)
|
||||
sxz → zckc(转细胞存储)
|
||||
```
|
||||
|
||||
> 注意:审核通过后才允许从 dsx → sxz。审核驳回后状态不变。
|
||||
|
||||
**实现指导:** 参照 `backend/app/crud/collection_protocol.py` 的整体结构,但去掉所有 payment/payment_detail/storage_cert 相关函数。CRUD 文件预计 600-800 行。
|
||||
|
||||
- [ ] **Step 1: 创建 crud/research_donation.py**
|
||||
|
||||
编写完整的 CRUD 文件,包含上述 11 个功能。关键点:
|
||||
- `create_donation` 接收一个包含所有子资源的 dict,在事务中创建主表 + party + collection + samples + transports
|
||||
- `update_donation` 对子资源使用"软删旧 + 插新"的 upsert 模式
|
||||
- `convert_from_storage` 读取 `BizCollectionProtocol` 及其子资源,映射到捐赠字段
|
||||
- `convert_to_storage` 反向操作
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/research_donation.py
|
||||
git commit -m "feat: 科研捐赠管理模块 — CRUD 业务逻辑层"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 后端 API 路由
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/research_donation.py`
|
||||
- Modify: `backend/app/api/__init__.py`
|
||||
- Modify: `backend/app/main.py`
|
||||
|
||||
**Reference:** `backend/app/api/collection_protocol.py`(API 路由模式)
|
||||
|
||||
- [ ] **Step 1: 创建 API 路由文件**
|
||||
|
||||
创建 `backend/app/api/research_donation.py`:
|
||||
|
||||
```python
|
||||
"""科研捐赠管理 — API 路由"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import research_donation as rd_svc
|
||||
from app.models.models import SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
|
||||
```
|
||||
|
||||
端点定义(参照 collection_protocol.py 的装饰器模式):
|
||||
|
||||
| 端点 | 方法 | 路径 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 列表 | GET | `/research-donations` | page/pageSize/keyword/donationStatus 参数 |
|
||||
| 详情 | GET | `/research-donations/{id}` | 返回主表 + 所有子资源 |
|
||||
| 创建 | POST | `/research-donations` | 整体提交含子资源 |
|
||||
| 更新 | PUT | `/research-donations/{id}` | 整体提交含子资源 |
|
||||
| 删除 | DELETE | `/research-donations/{id}` | 软删除 |
|
||||
| 批量删除 | POST | `/research-donations/batch-delete` | body: `{ids: []}` |
|
||||
| 审核列表 | GET | `/research-donations/audits` | page/pageSize/keyword/auditStatus |
|
||||
| 提交审核 | POST | `/research-donations/{id}/audit` | body: `{auditStatus, auditRemark}` |
|
||||
| 状态变更 | POST | `/research-donations/{id}/status-change` | body: `{donationStatus, desc, time}` |
|
||||
| 从存储转化 | POST | `/research-donations/convert-from-storage` | body: `{protocolId}` |
|
||||
| 转为存储 | POST | `/research-donations/{id}/convert-to-storage` | body: `{desc}` |
|
||||
|
||||
每个端点的模式:
|
||||
```python
|
||||
@router.get("/research-donations")
|
||||
def list_research_donations(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(20, ge=1, le=200),
|
||||
keyword: Optional[str] = Query(None),
|
||||
donationStatus: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
collector_user_id = _get_current_user_id(user)
|
||||
result = rd_svc.list_donations(db, tenant_no, page, pageSize, keyword, donationStatus, collector_user_id)
|
||||
return success_response(result)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册路由**
|
||||
|
||||
在 `backend/app/api/__init__.py` 增加:
|
||||
```python
|
||||
from app.api.research_donation import router as research_donation_router
|
||||
```
|
||||
并在 `__all__` 列表增加 `"research_donation_router"`。
|
||||
|
||||
在 `backend/app/main.py` 的 import 行和 include_router 行增加 `research_donation_router`。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/research_donation.py backend/app/api/__init__.py backend/app/main.py
|
||||
git commit -m "feat: 科研捐赠管理模块 — API 路由定义与注册"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端路由与 API 模块
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/api/research-donation.ts`
|
||||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||||
|
||||
**Reference:** `web-admin/src/api/collection-protocol.ts`(API 模式),`web-admin/src/router/modules/sqxp.ts:204-252`(路由注册模式)
|
||||
|
||||
- [ ] **Step 1: 创建 API 模块**
|
||||
|
||||
创建 `web-admin/src/api/research-donation.ts`:
|
||||
|
||||
```typescript
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type ResearchDonationRow = Record<string, unknown>
|
||||
|
||||
// 列表
|
||||
export function fetchResearchDonationList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
donationStatus?: string
|
||||
}) {
|
||||
return request.get<PageList<ResearchDonationRow>>({
|
||||
url: '/web/admin/research-donations',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 详情
|
||||
export function fetchResearchDonationDetail(id: string) {
|
||||
return request.get<{
|
||||
donation: Record<string, unknown>
|
||||
party: Record<string, unknown> | null
|
||||
collection: Record<string, unknown> | null
|
||||
samples: Array<Record<string, unknown>> | null
|
||||
attachments?: Array<Record<string, unknown>>
|
||||
}>({
|
||||
url: `/web/admin/research-donations/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 创建
|
||||
export function fetchCreateResearchDonation(data: Record<string, unknown>) {
|
||||
return request.post({
|
||||
url: '/web/admin/research-donations',
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 更新
|
||||
export function fetchUpdateResearchDonation(id: string, data: Record<string, unknown>) {
|
||||
return request.put({
|
||||
url: `/web/admin/research-donations/${id}`,
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
export function fetchDeleteResearchDonation(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/research-donations/${id}`,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
export function fetchBatchDeleteResearchDonation(ids: string[]) {
|
||||
return request.post({
|
||||
url: '/web/admin/research-donations/batch-delete',
|
||||
data: { ids },
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 审核列表
|
||||
export function fetchResearchDonationAuditList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
auditStatus?: string
|
||||
}) {
|
||||
return request.get<PageList<Record<string, unknown>>>({
|
||||
url: '/web/admin/research-donations/audits',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 提交审核
|
||||
export function fetchSubmitDonationAudit(
|
||||
donationId: string,
|
||||
data: { auditStatus: string; auditRemark?: string },
|
||||
options?: { showSuccessMessage?: boolean }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/research-donations/${donationId}/audit`,
|
||||
data,
|
||||
showSuccessMessage: options?.showSuccessMessage ?? true
|
||||
})
|
||||
}
|
||||
|
||||
// 状态变更
|
||||
export function fetchDonationStatusChange(
|
||||
donationId: string,
|
||||
data: { donationStatus: string; desc?: string; time?: string }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/research-donations/${donationId}/status-change`,
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 从细胞存储转化
|
||||
export function fetchConvertFromStorage(protocolId: string) {
|
||||
return request.post({
|
||||
url: '/web/admin/research-donations/convert-from-storage',
|
||||
data: { protocolId },
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 转为细胞存储
|
||||
export function fetchConvertToStorage(
|
||||
donationId: string,
|
||||
data?: { desc?: string }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/research-donations/${donationId}/convert-to-storage`,
|
||||
data: data || {},
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加路由定义**
|
||||
|
||||
在 `web-admin/src/router/modules/sqxp.ts` 的细胞存储路由组之后(约 line 252),增加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: '60000000000000000000000000000001',
|
||||
name: 'ResearchDonation',
|
||||
path: '/research-donation',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '科研捐赠管理',
|
||||
icon: 'ri:hand-heart-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '60000000000000000000000000000002',
|
||||
name: 'ResearchDonationList',
|
||||
path: 'list',
|
||||
component: '/research-donation/list/index',
|
||||
meta: { title: '捐赠协议单', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000003',
|
||||
name: 'ResearchDonationAudit',
|
||||
path: 'audit',
|
||||
component: '/research-donation/audit/index',
|
||||
meta: { title: '捐赠协议审核', keepAlive: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/research-donation.ts web-admin/src/router/modules/sqxp.ts
|
||||
git commit -m "feat: 科研捐赠管理模块 — 前端 API 模块与路由定义"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 前端 — 捐赠协议列表页
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/research-donation/list/index.vue`
|
||||
|
||||
**Reference:** `web-admin/src/views/cell-storage/protocol/index.vue`(列表页模式)
|
||||
|
||||
**实现指导:**
|
||||
|
||||
参照 `cell-storage/protocol/index.vue` 的完整模式创建列表页,关键差异:
|
||||
|
||||
1. **搜索条件**:关键字(编号/母亲姓名)+ 状态下拉(dsx/sxz/ygq/yzf/zckc)
|
||||
2. **表格列**:捐赠编号、捐赠日期、母亲姓名、状态、来源类型、采集专员、创建时间、操作
|
||||
3. **操作按钮**:新增、批量删除、导出(可选)
|
||||
4. **行操作**:查看、编辑、审核、状态变更、删除
|
||||
5. **状态标签颜色**:dsx=info, sxz=success, ygq=warning, yzf=danger, zckc=primary
|
||||
6. **无缴费相关按钮和筛选**(与细胞存储的关键区别)
|
||||
|
||||
组件导入:DonationFormDialog, DonationViewDialog, DonationAuditDialog, DonationStatusChangeDialog
|
||||
|
||||
- [ ] **Step 1: 创建列表页**
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/research-donation/list/index.vue
|
||||
git commit -m "feat: 科研捐赠管理模块 — 捐赠协议列表页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 前端 — 新增/编辑弹窗
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/research-donation/list/DonationFormDialog.vue`
|
||||
|
||||
**Reference:** `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue`(表单弹窗模式)
|
||||
|
||||
**实现指导:**
|
||||
|
||||
参照 ProtocolFormDialog.vue 的模式,5 个 Tab:
|
||||
|
||||
| Tab | 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| 基本信息 | donationNo, donationDate, sourceType, remark | 编号自动/手动、日期、来源类型只读 |
|
||||
| 当事人信息 | 母亲/父亲/新生儿全部字段 | 与细胞存储一致 |
|
||||
| 采集信息 | 区域/医院/采集人/时间 | 与细胞存储一致 |
|
||||
| 样本信息 | 样本类型/编码/数量/储存年限(**无 price 字段**) | 可多行添加删除 |
|
||||
| 附件 | 知情同意书(**必传**)+ 其他附件 | 知情同意书独立上传区域 |
|
||||
|
||||
关键点:
|
||||
- 宽度 `1200px`,`label-position="top"`
|
||||
- 知情同意书 Tab 中分两个区域:必传的知情同意书 + 可选的其他附件
|
||||
- 提交时校验 `consentFormUrls` 不能为空
|
||||
- 样本 Tab 无金额/费用相关字段
|
||||
|
||||
- [ ] **Step 1: 创建表单弹窗组件**
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/research-donation/list/DonationFormDialog.vue
|
||||
git commit -m "feat: 科研捐赠管理模块 — 新增/编辑弹窗(5 Tab)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 前端 — 查看/审核/状态变更弹窗
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/research-donation/list/DonationViewDialog.vue`
|
||||
- Create: `web-admin/src/views/research-donation/list/DonationAuditDialog.vue`
|
||||
- Create: `web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue`
|
||||
|
||||
**Reference:**
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue`
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolAuditDialog.vue`
|
||||
- `web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue`
|
||||
|
||||
- [ ] **Step 1: 创建 DonationViewDialog.vue**
|
||||
|
||||
参照 ProtocolViewDialog.vue,5 个 Tab 只读展示。宽度 `1600px`。
|
||||
|
||||
- [ ] **Step 2: 创建 DonationAuditDialog.vue**
|
||||
|
||||
参照 ProtocolAuditDialog.vue。审核状态选择(shtg/shth)+ 备注。支持批量。
|
||||
|
||||
- [ ] **Step 3: 创建 DonationStatusChangeDialog.vue**
|
||||
|
||||
参照 ProtocolStatusChangeDialog.vue。支持的操作:
|
||||
- 生效(dsx → sxz)
|
||||
- 过期(sxz → ygq)
|
||||
- 作废(dsx/sxz → yzf)
|
||||
- **转细胞存储**(sxz → zckc)— 新增操作
|
||||
|
||||
每个操作有时间 + 描述字段。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/research-donation/list/DonationViewDialog.vue \
|
||||
web-admin/src/views/research-donation/list/DonationAuditDialog.vue \
|
||||
web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue
|
||||
git commit -m "feat: 科研捐赠管理模块 — 查看/审核/状态变更弹窗"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 前端 — 审核管理页
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/research-donation/audit/index.vue`
|
||||
|
||||
**Reference:** `web-admin/src/views/cell-storage/protocol-audit/index.vue`
|
||||
|
||||
- [ ] **Step 1: 创建审核管理页**
|
||||
|
||||
参照 protocol-audit/index.vue 模式:
|
||||
- 列表展示待审核/已审核的捐赠记录
|
||||
- 批量审核、批量生效、批量作废等操作
|
||||
- 调用 `fetchResearchDonationAuditList` API
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/research-donation/audit/index.vue
|
||||
git commit -m "feat: 科研捐赠管理模块 — 审核管理页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: 细胞存储端 — 增加「转科研」操作
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue`
|
||||
|
||||
**Reference:** 读取现有文件,在状态变更操作中增加 `zky`(转科研)选项
|
||||
|
||||
- [ ] **Step 1: 修改 ProtocolStatusChangeDialog.vue**
|
||||
|
||||
在现有的状态变更操作中增加「转科研」选项:
|
||||
- 操作类型增加 `zky`
|
||||
- 调用 `fetchConvertFromStorage(protocolId)` API(从 research-donation.ts 导入)
|
||||
- 成功后刷新列表
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue
|
||||
git commit -m "feat: 细胞存储协议 — 增加转科研操作"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 联调与验证
|
||||
|
||||
- [ ] **Step 1: 后端验证**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
python -c "from app.models.research_donation import *; print('Models OK')"
|
||||
python -c "from app.api.research_donation import router; print('Router OK')"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 前端验证**
|
||||
|
||||
```bash
|
||||
cd web-admin && pnpm build
|
||||
```
|
||||
|
||||
预期:构建通过,无 TS 错误。
|
||||
|
||||
- [ ] **Step 3: 数据库建表**
|
||||
|
||||
在开发环境执行 `docs/sql/research_donation_tables.sql`。
|
||||
|
||||
- [ ] **Step 4: 功能验证清单**
|
||||
|
||||
| 功能 | 验证方式 |
|
||||
|------|---------|
|
||||
| 列表展示 | 访问 `/#/research-donation/list` |
|
||||
| 新增捐赠 | 填写 5 个 Tab,提交 |
|
||||
| 知情同意书必填校验 | 不传知情同意书,提交应报错 |
|
||||
| 编辑捐赠 | 修改后保存 |
|
||||
| 查看详情 | 5 Tab 只读展示 |
|
||||
| 删除(单个/批量) | 软删除 |
|
||||
| 审核流程 | 审核页面操作 |
|
||||
| 状态变更 | 生效/过期/作废 |
|
||||
| 转细胞存储 | 捐赠 → 细胞存储协议 |
|
||||
| 细胞存储转科研 | 细胞存储 → 科研捐赠 |
|
||||
@@ -0,0 +1,553 @@
|
||||
# 体检机构项目套餐(Phase 1)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在体检机构维度维护「项目套餐」(主子表),套餐总价为明细费用之和,明细价可编辑;机构项目软删除不阻断、不级联套餐行。
|
||||
|
||||
**Architecture:** 新增 `biz_checkup_institution_package` + `_package_item` 两张表;CRUD 追加到 `pilot_service.py`;API 在 `pilot_service.py` 路由文件增加 5 个端点,POST/PUT 一次提交 `items[]` 并在事务内全量替换明细;前端在 `checkup-institution/index.vue` 的「项目价格配置」弹窗内用 Tab 分离「项目列表 / 套餐管理」,套餐编辑交互对齐 `project-price-system` 主子表弹窗。
|
||||
|
||||
**Tech Stack:** Python 3.9 / FastAPI / SQLAlchemy / Pydantic v2;Vue 3 / Element Plus / TypeScript;MySQL 5.7
|
||||
|
||||
**Design Spec:** `docs/superpowers/specs/2026-05-18-checkup-project-package-design.md`
|
||||
|
||||
**SQL:** `docs/sql/checkup_institution_package.sql`
|
||||
|
||||
**Out of Scope (Phase 2):** 中试申请单体检「从套餐添加」展开预约明细
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| # | 文件 | 操作 | 职责 |
|
||||
|---|------|------|------|
|
||||
| 1 | `docs/sql/checkup_institution_package.sql` | 已有 | 建表 DDL(实施前在目标库执行) |
|
||||
| 2 | `docs/sql/zkxw.sql` | 追加 | 基线库同步两张新表(与增量 SQL 一致) |
|
||||
| 3 | `backend/app/models/models.py` | 追加 | `BizCheckupInstitutionPackage` / `Item` |
|
||||
| 4 | `backend/app/crud/pilot_service.py` | 追加 | 套餐 CRUD + 校验辅助函数 |
|
||||
| 5 | `backend/app/api/pilot_service.py` | 追加 | 路由 + Pydantic Body |
|
||||
| 6 | `web-admin/src/api/admin.ts` | 追加 | 5 个 fetch 函数 |
|
||||
| 7 | `web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue` | 新建 | 套餐 Tab:列表 + 主子表弹窗 + 选项目子弹窗 |
|
||||
| 8 | `web-admin/src/views/pilot-service/checkup-institution/index.vue` | 修改 | 项目配置弹窗改为 `el-tabs`;引入套餐面板 |
|
||||
| 9 | `web-admin/src/App.vue` | 按需 | 若套餐弹窗标题需竖条,在 `panelTitleBarTextRules` 登记(可选) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 数据库
|
||||
|
||||
**Files:**
|
||||
- Execute: `docs/sql/checkup_institution_package.sql`
|
||||
- Modify: `docs/sql/zkxw.sql`(在 `biz_checkup_institution_project` 表定义之后插入两张新表,内容与增量 SQL 一致)
|
||||
|
||||
- [ ] **Step 1: 在开发/测试库执行增量 SQL**
|
||||
|
||||
```bash
|
||||
mysql -h127.0.0.1 -uroot -p zkxw < docs/sql/checkup_institution_package.sql
|
||||
```
|
||||
|
||||
Expected: `Query OK`,`SHOW TABLES LIKE 'biz_checkup_institution_package%'` 返回 2 行。
|
||||
|
||||
- [ ] **Step 2: 将建表语句合并进 `docs/sql/zkxw.sql`**
|
||||
|
||||
在 `biz_checkup_institution_project` 段落后、`biz_clean_room` 之前插入 `checkup_institution_package.sql` 中两张 `CREATE TABLE`(勿重复 DROP 破坏全量脚本顺序时可只保留 CREATE)。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/sql/checkup_institution_package.sql docs/sql/zkxw.sql
|
||||
git commit -m "feat(db): add checkup institution package tables"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/models.py`(在 `BizCheckupInstitutionProject` 类之后、`# 生产管理` 注释之前)
|
||||
|
||||
- [ ] **Step 1: 追加两个 Model 类**
|
||||
|
||||
```python
|
||||
class BizCheckupInstitutionPackage(Base):
|
||||
"""体检机构项目套餐 biz_checkup_institution_package"""
|
||||
|
||||
__tablename__ = "biz_checkup_institution_package"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
checkup_institution_id = Column(String(64), nullable=False, comment="体检机构ID")
|
||||
package_name = Column(String(200), nullable=False, comment="套餐名称")
|
||||
package_code = Column(String(64), nullable=False, comment="套餐编码")
|
||||
status = Column(CHAR(1), nullable=False, default="1", comment="1正常 0停用")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizCheckupInstitutionPackageItem(Base):
|
||||
"""体检机构项目套餐明细 biz_checkup_institution_package_item"""
|
||||
|
||||
__tablename__ = "biz_checkup_institution_package_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
package_id = Column(String(64), nullable=False, comment="套餐ID")
|
||||
checkup_institution_project_id = Column(String(64), nullable=False, comment="机构项目ID")
|
||||
project_name = Column(String(200), nullable=False, comment="项目名称快照")
|
||||
project_fee = Column(Numeric(18, 2), nullable=True, comment="套餐内项目费用(元)")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 `pilot_service.py` CRUD 顶部 import**
|
||||
|
||||
在 `backend/app/crud/pilot_service.py` 的 `from app.models.models import (` 中追加:
|
||||
|
||||
```python
|
||||
BizCheckupInstitutionPackage,
|
||||
BizCheckupInstitutionPackageItem,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 import**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -c "from app.models.models import BizCheckupInstitutionPackage, BizCheckupInstitutionPackageItem; print('OK')"
|
||||
```
|
||||
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/models.py backend/app/crud/pilot_service.py
|
||||
git commit -m "feat: add SQLAlchemy models for checkup institution packages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 CRUD
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/pilot_service.py`(在 `soft_delete_checkup_institution_project` 之后、`# Shared utility` 之前新增 Entity 14 段)
|
||||
|
||||
- [ ] **Step 1: 实现字典转换与辅助函数**
|
||||
|
||||
新增函数(命名固定,便于 API 层调用):
|
||||
|
||||
| 函数 | 职责 |
|
||||
|------|------|
|
||||
| `_row_to_dict_checkup_institution_package(row, *, total_fee=None, item_count=None)` | 头表 camelCase + `totalFee` / `itemCount` |
|
||||
| `_row_to_dict_checkup_institution_package_item(row, *, source_project_deleted=False)` | 明细 + `sourceProjectDeleted` |
|
||||
| `_sum_package_item_fees(items)` | `sum(project_fee or 0)` |
|
||||
| `checkup_package_code_exists(db, institution_id, code, tenant_no, exclude_id=None)` | 同机构 `del_flag=0` 编码唯一 |
|
||||
| `_get_checkup_project_for_package(db, project_id, institution_id, tenant_no)` | 查源项目,不存在返回 None |
|
||||
| `_validate_package_items_for_save(db, institution_id, items, tenant_no, existing_item_project_ids=None)` | 见下方规则 |
|
||||
|
||||
**`_validate_package_items_for_save` 规则:**
|
||||
|
||||
1. `items` 至少 1 条;同一请求内 `checkup_institution_project_id` 不重复。
|
||||
2. 对每条明细:
|
||||
- 若 `project_id` 属于 `existing_item_project_ids`(更新时保留的历史行):允许源项目 `del_flag=1`,但须校验曾属于本机构。
|
||||
- 否则:源项目须 `del_flag=0`、`status=1`、且 `checkup_institution_id` 匹配。
|
||||
3. 若任一新添加行源项目 `status=0`:抛出 `ValueError("套餐不能包含已停用的体检项目")`。
|
||||
|
||||
- [ ] **Step 2: 实现列表 / 详情 / 创建 / 更新 / 删除**
|
||||
|
||||
```python
|
||||
def list_checkup_institution_packages(db, checkup_institution_id, tenant_no=None) -> List[Dict[str, Any]]:
|
||||
# 查头表 del_flag=0,按 create_time desc
|
||||
# 对每个 package 查明细 del_flag=0,计算 totalFee、itemCount
|
||||
...
|
||||
|
||||
def get_checkup_institution_package(db, package_id, tenant_no=None):
|
||||
# 头表单条,不存在返回 None
|
||||
...
|
||||
|
||||
def get_checkup_institution_package_detail(db, package_id, tenant_no=None) -> Optional[Dict[str, Any]]:
|
||||
# 头表 dict + items[](join 源项目 del_flag 填 sourceProjectDeleted)
|
||||
...
|
||||
|
||||
def create_checkup_institution_package(
|
||||
db, *, checkup_institution_id, package_name, package_code, status="1",
|
||||
items, tenant_no, order_num=None, remark=None, user_id=None,
|
||||
):
|
||||
# 1. checkup_package_code_exists → ValueError
|
||||
# 2. _validate_package_items_for_save(..., existing_item_project_ids=None)
|
||||
# 3. 插入头表 + 循环插入明细(project_name 优先 body,否则源项目名)
|
||||
# 4. commit;返回 get_checkup_institution_package_detail(...)
|
||||
...
|
||||
|
||||
def update_checkup_institution_package(
|
||||
db, package_id, *, package_name=None, package_code=None, status=None,
|
||||
items=None, order_num=None, remark=None, user_id=None, tenant_no=None,
|
||||
):
|
||||
# 1. get 头表
|
||||
# 2. 若改 package_code 做唯一校验
|
||||
# 3. 若 items is not None:
|
||||
# - 读取旧明细 project_id 集合 → existing_item_project_ids
|
||||
# - _validate_package_items_for_save(..., existing_item_project_ids=...)
|
||||
# - 旧明细全部 soft_delete (del_flag=1)
|
||||
# - 插入新明细行(新 id)
|
||||
# 4. commit;返回 detail
|
||||
...
|
||||
|
||||
def soft_delete_checkup_institution_package(db, package_id, tenant_no=None):
|
||||
# 头表 del_flag=1;其下明细 del_flag=1
|
||||
...
|
||||
```
|
||||
|
||||
**机构项目删除:** 确认 `soft_delete_checkup_institution_project` **不增加**套餐引用检查(保持现状即可,在 commit message 中注明符合设计)。
|
||||
|
||||
- [ ] **Step 3: 本地 CRUD 冒烟(可选,Django shell 等价)**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python - <<'PY'
|
||||
from app.core.database import SessionLocal
|
||||
from app.crud import pilot_service as ps
|
||||
db = SessionLocal()
|
||||
# 替换为库内真实 institution_id / project_id
|
||||
inst_id = "REPLACE"
|
||||
proj_id = "REPLACE"
|
||||
try:
|
||||
detail = ps.create_checkup_institution_package(
|
||||
db,
|
||||
checkup_institution_id=inst_id,
|
||||
package_name="基础套餐",
|
||||
package_code="PKG_BASE_01",
|
||||
status="1",
|
||||
items=[{"checkup_institution_project_id": proj_id, "project_name": "血常规", "project_fee": 100.0}],
|
||||
tenant_no="zkxw",
|
||||
user_id="admin",
|
||||
)
|
||||
print("totalFee", detail.get("totalFee"), "items", len(detail.get("items", [])))
|
||||
finally:
|
||||
db.close()
|
||||
PY
|
||||
```
|
||||
|
||||
Expected: 打印 `totalFee 100.0 items 1`(无异常;测试后可软删该套餐)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/pilot_service.py
|
||||
git commit -m "feat: add CRUD for checkup institution project packages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 后端 API 路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/pilot_service.py`(在体检机构项目路由段之后追加)
|
||||
|
||||
- [ ] **Step 1: 定义 Pydantic Body**
|
||||
|
||||
```python
|
||||
class CheckupPackageItemBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
checkup_institution_project_id: str = Field(..., alias="checkupInstitutionProjectId")
|
||||
project_name: Optional[str] = Field(None, alias="projectName")
|
||||
project_fee: Optional[float] = Field(None, alias="projectFee")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CheckupInstitutionPackageCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
package_name: str = Field(..., alias="packageName")
|
||||
package_code: str = Field(..., alias="packageCode")
|
||||
status: Optional[str] = Field("1", alias="status")
|
||||
order_num: Optional[int] = Field(0, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
items: List[CheckupPackageItemBody] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CheckupInstitutionPackageUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
package_name: Optional[str] = Field(None, alias="packageName")
|
||||
package_code: Optional[str] = Field(None, alias="packageCode")
|
||||
status: Optional[str] = Field(None, alias="status")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
items: Optional[List[CheckupPackageItemBody]] = None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 注册 5 个路由**
|
||||
|
||||
| 方法 | 路径 | 处理 |
|
||||
|------|------|------|
|
||||
| GET | `/checkup-institutions/{institution_id}/packages` | `list_checkup_institution_packages` → `success_response(list)` |
|
||||
| POST | `/checkup-institutions/{institution_id}/packages` | `create_checkup_institution_package`,body 转 `items` 为 dict 列表 |
|
||||
| GET | `/checkup-institution-packages/{package_id}` | `get_checkup_institution_package_detail` |
|
||||
| PUT | `/checkup-institution-packages/{package_id}` | `update_checkup_institution_package` |
|
||||
| DELETE | `/checkup-institution-packages/{package_id}` | `soft_delete_checkup_institution_package` |
|
||||
|
||||
权限:`require_admin_or_super_admin`;`tenant_no = _get_tenant_no(db, user)`。
|
||||
|
||||
- [ ] **Step 3: 启动后端并用 curl 验证(需有效 JWT)**
|
||||
|
||||
```bash
|
||||
# 登录后替换 TOKEN、INSTITUTION_ID
|
||||
curl -s -H "Authorization: Bearer TOKEN" \
|
||||
"http://127.0.0.1:8000/api/web/admin/checkup-institutions/INSTITUTION_ID/packages" | head -c 500
|
||||
```
|
||||
|
||||
Expected: `code:200`,`data` 为数组(可为 `[]`)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/pilot_service.py
|
||||
git commit -m "feat: add REST API for checkup institution project packages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端 API
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/admin.ts`(在 `fetchDeleteCheckupInstitutionProject` 之后)
|
||||
|
||||
- [ ] **Step 1: 追加 5 个函数**
|
||||
|
||||
```typescript
|
||||
// 中试服务管理 — 体检机构项目套餐
|
||||
export function fetchCheckupInstitutionPackages(institutionId: string) {
|
||||
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/packages` })
|
||||
}
|
||||
|
||||
export function fetchCheckupInstitutionPackageDetail(packageId: string) {
|
||||
return request.get({ url: `/web/admin/checkup-institution-packages/${packageId}` })
|
||||
}
|
||||
|
||||
export function fetchCreateCheckupInstitutionPackage(
|
||||
institutionId: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/checkup-institutions/${institutionId}/packages`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateCheckupInstitutionPackage(
|
||||
packageId: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
return request.put({ url: `/web/admin/checkup-institution-packages/${packageId}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCheckupInstitutionPackage(packageId: string) {
|
||||
return request.del({ url: `/web/admin/checkup-institution-packages/${packageId}` })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/admin.ts
|
||||
git commit -m "feat(web-admin): add API helpers for checkup institution packages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 前端 — `CheckupPackagePanel.vue`
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue`
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
const props = defineProps<{
|
||||
institutionId: string
|
||||
institutionName: string
|
||||
}>()
|
||||
```
|
||||
|
||||
- [ ] **Step 1: 套餐列表区**
|
||||
|
||||
- 工具栏:已选 N 项 +「新增套餐」(唯一 `type="primary"`)
|
||||
- 表格列:序号、套餐名称、套餐编码、项目数(`itemCount`)、合计费用(`totalFee`)、状态(tag)、备注、操作(编辑/删除)
|
||||
- `loadPackages()` → `fetchCheckupInstitutionPackages(props.institutionId)`,解析 `Array.isArray(res) ? res : res?.list ?? []`
|
||||
|
||||
- [ ] **Step 2: 套餐新增/编辑弹窗(主子表)**
|
||||
|
||||
对齐 `project-price-system/index.vue`:
|
||||
|
||||
**头部表单**(`form-grid-2`,`label-position="top"`):
|
||||
|
||||
| 字段 | 校验 |
|
||||
|------|------|
|
||||
| packageName | required |
|
||||
| packageCode | required |
|
||||
| status | `1` / `0` |
|
||||
| remark | 可选 |
|
||||
|
||||
**明细区**:
|
||||
|
||||
- 按钮「添加项目」→ 打开子弹窗
|
||||
- 表格列:序号、项目名称(+ `sourceProjectDeleted` 时 `<span class="form-label-tip">源项目已删除</span>`)、项目费用(可编辑 `el-input-number`)、操作(删除行)
|
||||
- 底部:`套餐合计:¥ {{ packageTotalFee }}`(computed,`detailItems` 的 `projectFee` 求和)
|
||||
|
||||
**提交 payload:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
packageName: string,
|
||||
packageCode: string,
|
||||
status: string,
|
||||
remark?: string,
|
||||
items: detailItems.value.map((row, idx) => ({
|
||||
checkupInstitutionProjectId: row.checkupInstitutionProjectId,
|
||||
projectName: row.projectName,
|
||||
projectFee: row.projectFee,
|
||||
orderNum: idx + 1
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
- 新增:`fetchCreateCheckupInstitutionPackage(institutionId, payload)`
|
||||
- 编辑:`fetchUpdateCheckupInstitutionPackage(id, payload)`;打开时 `fetchCheckupInstitutionPackageDetail(id)` 填充 `detailItems`
|
||||
|
||||
- [ ] **Step 3: 「添加项目」子弹窗**
|
||||
|
||||
- 调用已有 `fetchCheckupInstitutionProjects(props.institutionId)`
|
||||
- 仅展示 `status == '1'` 且未在 `detailItems` 中的项目
|
||||
- 多选确认后 push:
|
||||
|
||||
```typescript
|
||||
{
|
||||
checkupInstitutionProjectId: row.id,
|
||||
projectName: row.projectName,
|
||||
projectFee: row.projectFee ?? 0,
|
||||
sourceProjectDeleted: false
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 删除套餐**
|
||||
|
||||
`ElMessageBox.confirm` → `fetchDeleteCheckupInstitutionPackage` → `loadPackages()`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue
|
||||
git commit -m "feat(web-admin): add checkup package panel component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 前端 — 接入 `index.vue` Tab
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/pilot-service/checkup-institution/index.vue`
|
||||
|
||||
- [ ] **Step 1: 将「项目价格配置」弹窗正文改为 `el-tabs`**
|
||||
|
||||
```vue
|
||||
<el-tabs v-model="itemConfigTab" class="item-config-tabs">
|
||||
<el-tab-pane label="项目列表" name="projects">
|
||||
<!-- 现有 list-toolbar + el-table + 项目嵌套弹窗 移入此 pane -->
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="套餐管理" name="packages">
|
||||
<CheckupPackagePanel
|
||||
v-if="itemConfigTab === 'packages' && currentInstitutionId"
|
||||
:institution-id="currentInstitutionId"
|
||||
:institution-name="currentInstitutionName"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: script 增加**
|
||||
|
||||
```typescript
|
||||
import CheckupPackagePanel from './CheckupPackagePanel.vue'
|
||||
const itemConfigTab = ref<'projects' | 'packages'>('projects')
|
||||
```
|
||||
|
||||
- `openItemDialog` 时重置 `itemConfigTab = 'projects'`
|
||||
|
||||
- [ ] **Step 3: 弹窗宽度**
|
||||
|
||||
项目配置弹窗 `width` 调至 `960px` 或 `1000px`,避免 Tab 内表格拥挤。
|
||||
|
||||
- [ ] **Step 4: 机构操作列(可选)**
|
||||
|
||||
主列表操作列可增加「套餐配置」按钮,`openItemDialog(row)` 且 `itemConfigTab = 'packages'`(非必须,Tab 已可达)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pilot-service/checkup-institution/index.vue
|
||||
git commit -m "feat(web-admin): integrate package tab in checkup institution config dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 联调与验收
|
||||
|
||||
- [ ] **Step 1: 后端 lint / 启动**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && python -c "from app.api.pilot_service import router; print('routes OK')"
|
||||
cd web-admin && pnpm lint
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 手工验收清单(Phase 1)**
|
||||
|
||||
| # | 场景 | 预期 |
|
||||
|---|------|------|
|
||||
| 1 | 新增套餐,编码必填,选 2 个项目并改价 | 保存成功;列表 `totalFee` = 两行费用之和 |
|
||||
| 2 | 同机构重复套餐编码 | 接口 400,提示编码已存在 |
|
||||
| 3 | 套餐含停用项目(新加行) | 保存失败 |
|
||||
| 4 | 编辑套餐:删除一行、改价 | 合计随动;保存后列表更新 |
|
||||
| 5 | 软删除某机构项目(被套餐引用) | 删除成功;编辑套餐见「源项目已删除」,合计仍含该行 |
|
||||
| 6 | 删除套餐 | 列表不再显示 |
|
||||
|
||||
- [ ] **Step 3: Commit(若有验收期小修)**
|
||||
|
||||
```bash
|
||||
git commit -m "fix: checkup package QA adjustments after manual test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec Coverage(自检)
|
||||
|
||||
| 设计要求 | 任务 |
|
||||
|----------|------|
|
||||
| 套餐编码必填、机构内唯一 | Task 3 `checkup_package_code_exists`;Task 6 表单校验 |
|
||||
| 总价 = 明细之和、不落库 | Task 3 `_sum_package_item_fees`;Task 6 computed |
|
||||
| 明细价可改、不回写机构项目 | Task 3 只写 package_item;Task 6 可编辑列 |
|
||||
| 机构项目软删不拦截、明细保留 | Task 3 校验 + `sourceProjectDeleted`;Task 3 不改 delete 逻辑 |
|
||||
| 含停用项禁止保存(新行) | Task 3 `_validate_package_items_for_save` |
|
||||
| 5 个 API 端点 | Task 4 |
|
||||
| Tab:项目 / 套餐 | Task 7 |
|
||||
| Phase 2 从套餐带入 | **未纳入本计划** |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 备忘(本计划不实施)
|
||||
|
||||
中试申请单体检模块开发时:
|
||||
|
||||
- 选机构 →「从套餐添加」→ 仅展开 `del_flag=0 && status=1` 的源项目行
|
||||
- 写入 `biz_pilot_checkup_reserve_item` 快照
|
||||
|
||||
详见设计文档 §6。
|
||||
@@ -0,0 +1,359 @@
|
||||
# 中试服务管理 — 6 模块实现设计规格
|
||||
|
||||
> 日期: 2026-05-17
|
||||
> 状态: DRAFT
|
||||
> 需求来源: `docs/中科兴皖需求V0.3.md` §2.3
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
一次性实现中试服务管理下 6 个基础数据管理模块,涵盖后端 API、前端页面、路由和菜单配置。数据库表已全部建好(`docs/sql/zkxw.sql`),无数据。
|
||||
|
||||
### 模块清单
|
||||
|
||||
| # | 模块 | 表 | 前端模板 | 复杂度 |
|
||||
|---|------|-----|----------|--------|
|
||||
| 2.3.1 | 细胞产品类型管理 | `biz_cell_product_type` | `unit/index.vue` | 简单 CRUD |
|
||||
| 2.3.2 | 细胞产品规格管理 | `biz_cell_product_spec` | `unit/index.vue` | 简单 CRUD |
|
||||
| 2.3.3 | 细胞产品价格体系管理 | `biz_cell_product_price` | `unit/index.vue` | 中等 — 含下拉关联 |
|
||||
| 2.3.4 | 细胞产品项目价格管理 | `biz_cell_product_project_system` + `biz_cell_product_project_system_item` | `category/index.vue` | 较复杂 — 主子表 |
|
||||
| 2.3.5 | 渠道信息管理 | `biz_channel` + 4 张子表 | `product/index.vue` | 复杂 — 多 tab + 子表 |
|
||||
| 2.3.6 | 体检机构及项目价格 | `biz_checkup_institution` + 2 张子表 | `category/index.vue` | 较复杂 — 主子表 |
|
||||
|
||||
### 实现顺序
|
||||
|
||||
```
|
||||
2.3.1 → 2.3.2 → 2.3.3 → 2.3.4 → 2.3.6 → 2.3.5
|
||||
```
|
||||
|
||||
理由:2.3.3 依赖 2.3.1/2.3.2 的下拉数据;2.3.4 依赖 2.3.3;2.3.5 渠道协议价格引用 2.3.1/2.3.2/2.3.3。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构决策
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| 后端路由文件 | `backend/app/api/pilot_service.py` 单文件 | 6 模块关联紧密,跟随 `basic_info.py` 模式 |
|
||||
| 后端 CRUD 文件 | `backend/app/crud/pilot_service.py` 单文件 | 与路由文件对应 |
|
||||
| 后端 Model | 在 `backend/app/models/models.py` 中追加 | 遵循现有单文件模式 |
|
||||
| 前端 API | 在 `web-admin/src/api/admin.ts` 中追加 | 遵循现有模式 |
|
||||
| 前端页面目录 | `web-admin/src/views/pilot-service/{module}/index.vue` | 独立一级路由 |
|
||||
| 前端路由 | `web-admin/src/router/modules/sqxp.ts` 中追加 | 现有中试服务路由位置 |
|
||||
| 菜单 SQL | `docs/sql/sys_menu_pilot_service.sql` | 独立增量 SQL |
|
||||
| 渠道协议价格来源 | 同时支持价格体系(2.3.3)和项目价格体系(2.3.4) | 用户确认,方便渠道自由组合 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端设计
|
||||
|
||||
### 3.1 Model(`backend/app/models/models.py`)
|
||||
|
||||
在文件末尾追加 13 个 SQLAlchemy Model 类。所有 Model 遵循现有模式:
|
||||
|
||||
```
|
||||
id → 业务字段 → tenant_no, order_num, remark, create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
```
|
||||
|
||||
新增 Model 清单:
|
||||
|
||||
1. `BizCellProductType` — `biz_cell_product_type`
|
||||
2. `BizCellProductSpec` — `biz_cell_product_spec`
|
||||
3. `BizCellProductPrice` — `biz_cell_product_price`
|
||||
4. `BizCellProductProjectSystem` — `biz_cell_product_project_system`
|
||||
5. `BizCellProductProjectSystemItem` — `biz_cell_product_project_system_item`
|
||||
6. `BizChannel` — `biz_channel`
|
||||
7. `BizChannelAgreement` — `biz_channel_agreement`
|
||||
8. `BizChannelAgreementProductPrice` — `biz_channel_agreement_product_price`
|
||||
9. `BizChannelInvoice` — `biz_channel_invoice`
|
||||
10. `BizChannelMailAddress` — `biz_channel_mail_address`
|
||||
11. `BizCheckupInstitution` — `biz_checkup_institution`
|
||||
12. `BizCheckupInstitutionInvoice` — `biz_checkup_institution_invoice`
|
||||
13. `BizCheckupInstitutionProject` — `biz_checkup_institution_project`
|
||||
|
||||
### 3.2 CRUD(`backend/app/crud/pilot_service.py`)
|
||||
|
||||
单文件,按模块分段组织,每段包含:
|
||||
|
||||
- `_row_to_dict_xxx()` — 转换为 camelCase 字典
|
||||
- `list_xxx()` — 分页列表 + keyword 搜索 + tenant_no 过滤
|
||||
- `get_xxx()` — 单条查询
|
||||
- `xxx_code_exists()` — 唯一性校验(编码字段)
|
||||
- `create_xxx()` — 新增
|
||||
- `update_xxx()` — 更新
|
||||
- `soft_delete_xxx()` — 软删除
|
||||
|
||||
子表额外函数:
|
||||
- `list_xxx_items(parent_id)` — 按 parent_id 查子表列表
|
||||
- `create_xxx_item()` / `update_xxx_item()` / `soft_delete_xxx_item()` — 子表 CRUD
|
||||
|
||||
### 3.3 Route(`backend/app/api/pilot_service.py`)
|
||||
|
||||
单文件,`APIRouter(prefix="/web/admin", tags=["中试服务管理"])`。
|
||||
|
||||
每个实体提供 4 个标准端点:
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/cell-product-types` | 分页列表 | `require_menu_code` |
|
||||
| POST | `/cell-product-types` | 新增 | `require_admin_or_super_admin` |
|
||||
| PUT | `/cell-product-types/{id}` | 更新 | `require_admin_or_super_admin` |
|
||||
| DELETE | `/cell-product-types/{id}` | 删除 | `require_admin_or_super_admin` |
|
||||
|
||||
子表额外端点(如项目价格体系明细、渠道子表等):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/cell-product-project-systems/{id}/items` | 获取子表明细 |
|
||||
| POST | `/cell-product-project-systems/{id}/items` | 新增子表行 |
|
||||
| PUT | `/project-system-items/{id}` | 更新子表行 |
|
||||
| DELETE | `/project-system-items/{id}` | 删除子表行 |
|
||||
|
||||
Pydantic Body Model 定义在路由文件内部,使用 `alias` 支持 camelCase。
|
||||
|
||||
渠道信息管理(2.3.5)因其复杂度,需要以下额外端点:
|
||||
- 渠道基础信息 CRUD(含渠道协议附件上传)
|
||||
- 渠道开票信息 CRUD(子表)
|
||||
- 渠道邮寄地址 CRUD(子表)
|
||||
- 渠道协议 CRUD(子表,含状态变更:生效/作废)
|
||||
- 渠道协议-产品价格 CRUD(协议的子表)
|
||||
- 协议状态变更端点:`PUT /channel-agreements/{id}/activate` 和 `PUT /channel-agreements/{id}/void`
|
||||
|
||||
### 3.4 路由注册
|
||||
|
||||
1. `backend/app/api/__init__.py` — 添加 `from app.api.pilot_service import router as pilot_service_router` + `__all__`
|
||||
2. `backend/app/main.py` — `from app.api import ..., pilot_service_router` + `app.include_router(pilot_service_router, prefix=settings.API_V1_PREFIX)`
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端设计
|
||||
|
||||
### 4.1 API 函数(`web-admin/src/api/admin.ts`)
|
||||
|
||||
追加以下 API 函数(每个实体 4 个:list / create / update / delete):
|
||||
|
||||
```
|
||||
// 2.3.1 细胞产品类型
|
||||
fetchCellProductTypeList / fetchCreateCellProductType / fetchUpdateCellProductType / fetchDeleteCellProductType
|
||||
|
||||
// 2.3.2 细胞产品规格
|
||||
fetchCellProductSpecList / fetchCreateCellProductSpec / fetchUpdateCellProductSpec / fetchDeleteCellProductSpec
|
||||
|
||||
// 2.3.3 细胞产品价格体系
|
||||
fetchCellProductPriceList / fetchCreateCellProductPrice / fetchUpdateCellProductPrice / fetchDeleteCellProductPrice
|
||||
|
||||
// 2.3.4 细胞产品项目价格体系
|
||||
fetchProjectSystemList / fetchCreateProjectSystem / fetchUpdateProjectSystem / fetchDeleteProjectSystem
|
||||
fetchProjectSystemItems / fetchCreateProjectSystemItem / fetchUpdateProjectSystemItem / fetchDeleteProjectSystemItem
|
||||
|
||||
// 2.3.5 渠道信息
|
||||
fetchChannelList / fetchCreateChannel / fetchUpdateChannel / fetchDeleteChannel
|
||||
fetchChannelInvoices / fetchCreateChannelInvoice / fetchUpdateChannelInvoice / fetchDeleteChannelInvoice
|
||||
fetchChannelMailAddresses / fetchCreateChannelMailAddress / fetchUpdateChannelMailAddress / fetchDeleteChannelMailAddress
|
||||
fetchChannelAgreements / fetchCreateChannelAgreement / fetchUpdateChannelAgreement / fetchDeleteChannelAgreement
|
||||
fetchChannelAgreementPrices / fetchCreateChannelAgreementPrice / fetchUpdateChannelAgreementPrice / fetchDeleteChannelAgreementPrice
|
||||
fetchActivateChannelAgreement / fetchVoidChannelAgreement
|
||||
|
||||
// 2.3.6 体检机构
|
||||
fetchCheckupInstitutionList / fetchCreateCheckupInstitution / fetchUpdateCheckupInstitution / fetchDeleteCheckupInstitution
|
||||
fetchCheckupInstitutionInvoices / fetchCreateCheckupInstitutionInvoice / ...
|
||||
fetchCheckupInstitutionProjects / fetchCreateCheckupInstitutionProject / ...
|
||||
```
|
||||
|
||||
### 4.2 页面文件
|
||||
|
||||
```
|
||||
web-admin/src/views/pilot-service/
|
||||
├── cell-product-type/index.vue # 2.3.1 — 复制 unit/index.vue
|
||||
├── cell-product-spec/index.vue # 2.3.2 — 复制 unit/index.vue
|
||||
├── cell-product-price/index.vue # 2.3.3 — 复制 unit/index.vue,表单含下拉关联
|
||||
├── project-price-system/index.vue # 2.3.4 — 复制 category/index.vue,主子表
|
||||
├── channel/index.vue # 2.3.5 — 复制 product/index.vue,多 tab + 子表
|
||||
└── checkup-institution/index.vue # 2.3.6 — 复制 category/index.vue,主子表
|
||||
```
|
||||
|
||||
### 4.3 页面详细设计
|
||||
|
||||
#### 2.3.1 细胞产品类型管理(简单 CRUD)
|
||||
|
||||
- **列表列**: 序号、类型编码(`product_type_code`)、类型名称(`product_type_name`)、排序号、备注、创建时间、操作
|
||||
- **新增/编辑弹窗**: 2 列表单,字段:类型编码(必填)、类型名称(必填)、排序号、备注
|
||||
- **查看弹窗**: `SystemViewDialog`,展示基本信息
|
||||
- **搜索**: keyword 搜索编码/名称
|
||||
|
||||
#### 2.3.2 细胞产品规格管理(简单 CRUD)
|
||||
|
||||
- **列表列**: 序号、规格编码(`spec_code`)、规格名称(`spec_name`)、排序号、备注、创建时间、操作
|
||||
- **新增/编辑弹窗**: 2 列表单,字段:规格编码(必填)、规格名称(必填)、排序号、备注
|
||||
- **查看弹窗**: `SystemViewDialog`
|
||||
- **搜索**: keyword 搜索编码/名称
|
||||
|
||||
#### 2.3.3 细胞产品价格体系管理(含下拉关联)
|
||||
|
||||
- **列表列**: 序号、项目名称(`project_name`)、项目编码(`project_code`)、细胞类型名称(`cell_type_name`)、规格(`spec_text`)、单价(`unit_price`)、排序号、备注、创建时间、操作
|
||||
- **新增/编辑弹窗**: 2 列表单,字段:
|
||||
- 项目名称(必填)、项目编码(必填)
|
||||
- 细胞产品类型(下拉,从 2.3.1 获取,选择后填充 `cell_type_code` + `cell_type_name`)
|
||||
- 规格(下拉,从 2.3.2 获取,选择后填充 `spec_id` + `spec_text`)
|
||||
- 单价(元,数字输入)、排序号、备注
|
||||
- **搜索**: keyword + 细胞类型下拉筛选
|
||||
|
||||
#### 2.3.4 细胞产品项目价格管理(主子表)
|
||||
|
||||
- **列表列**: 序号、体系名称(`system_name`)、体系编码(`system_code`)、状态(tag)、排序号、备注、创建时间、操作
|
||||
- **新增/编辑弹窗**:
|
||||
- 顶部:体系名称(必填)、体系编码(必填)、状态(下拉:正常/停用)、排序号、备注
|
||||
- 下方:明细表格(可编辑表格)
|
||||
- 操作列:添加行(从价格体系 2.3.3 选择)、删除行
|
||||
- 列:项目名称、项目编码、细胞类型名称、规格、单价(可修改)
|
||||
- 选择后自动填充:`source_price_id`, `project_name`, `project_code`, `cell_type_code`, `cell_type_name`, `spec_text`, `spec_id`, `cell_product_type_id`
|
||||
- `unit_price` 默认带入选中的价格,但可修改
|
||||
- **查看弹窗**: 展示主表信息 + 明细列表
|
||||
|
||||
#### 2.3.5 渠道信息管理(多 Tab + 子表)
|
||||
|
||||
- **列表列**: 序号、渠道名称(`channel_name`)、统一社会信用代码(`credit_code`)、渠道类型(tag)、联系人、联系电话、排序号、创建时间、操作
|
||||
- **新增/编辑弹窗**: 使用 Tab 分区
|
||||
- **Tab 1 基础信息**: 渠道名称(必填)、统一社会信用代码、渠道类型(下拉:yy/hzjg/zs/zfjg/qt)、联系人姓名、联系电话、联系邮箱、渠道协议附件(上传)、排序号、备注
|
||||
- **Tab 2 开票信息**: 子表列表 + 新增/编辑/删除
|
||||
- 字段:单位名称(必填)、税号、单位地址、电话、开户银行、银行账号
|
||||
- **Tab 3 邮寄地址**: 子表列表 + 新增/编辑/删除
|
||||
- 字段:邮寄地址(必填)、邮寄联系人、邮寄联系电话
|
||||
- **Tab 4 协议信息**: 子表列表 + 新增/编辑/删除
|
||||
- 每个协议可展开查看/编辑协议价格明细
|
||||
- 协议字段:协议类型(必填,下拉 kyxy/zsxy/lcxy)、业务类型(随协议类型自动填充)、结算方式(下拉)、授信额度、生效日期、到期日期、协议状态(tag)
|
||||
- 操作按钮:生效、作废(仅状态变更)
|
||||
- **协议价格明细子表**:
|
||||
- 可从价格体系(2.3.3)或项目价格体系(2.3.4)中选择添加
|
||||
- 选择后自动填充:项目名称、项目编码、细胞类型编码、细胞类型名称、规格
|
||||
- 原价(自动带入,可修改)、协议价(必填)
|
||||
|
||||
#### 2.3.6 体检机构及项目价格(主子表)
|
||||
|
||||
- **列表列**: 序号、机构名称(`institution_name`)、机构地址、联系人、联系电话、状态(tag)、排序号、创建时间、操作
|
||||
- **新增/编辑弹窗**: 使用 Tab 分区
|
||||
- **Tab 1 基础信息**: 机构名称(必填)、机构地址、联系人、联系电话、状态(正常/停用)、排序号、备注
|
||||
- **Tab 2 开票信息**: 子表列表 + 新增/编辑/删除
|
||||
- 字段:单位名称(必填)、税号、单位地址、电话、开户银行、银行账号
|
||||
- **Tab 3 项目及价格**: 子表列表 + 新增/编辑/删除
|
||||
- 字段:项目名称(必填)、项目费用(元)
|
||||
|
||||
### 4.4 路由配置
|
||||
|
||||
在 `web-admin/src/router/modules/sqxp.ts` 中添加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: '80000000000000000000000000000001',
|
||||
name: 'PilotService',
|
||||
path: '/pilot-service',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '中试服务管理',
|
||||
icon: 'ri:flask-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '80000000000000000000000000000002',
|
||||
name: 'CellProductType',
|
||||
path: 'cell-product-type',
|
||||
component: '/pilot-service/cell-product-type/index',
|
||||
meta: { title: '细胞产品类型管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000003',
|
||||
name: 'CellProductSpec',
|
||||
path: 'cell-product-spec',
|
||||
component: '/pilot-service/cell-product-spec/index',
|
||||
meta: { title: '细胞产品规格管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000004',
|
||||
name: 'CellProductPrice',
|
||||
path: 'cell-product-price',
|
||||
component: '/pilot-service/cell-product-price/index',
|
||||
meta: { title: '细胞产品价格体系管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000005',
|
||||
name: 'ProjectPriceSystem',
|
||||
path: 'project-price-system',
|
||||
component: '/pilot-service/project-price-system/index',
|
||||
meta: { title: '细胞产品项目价格管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000006',
|
||||
name: 'Channel',
|
||||
path: 'channel',
|
||||
component: '/pilot-service/channel/index',
|
||||
meta: { title: '渠道信息管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000007',
|
||||
name: 'CheckupInstitution',
|
||||
path: 'checkup-institution',
|
||||
component: '/pilot-service/checkup-institution/index',
|
||||
meta: { title: '体检机构及项目价格', keepAlive: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 菜单 SQL
|
||||
|
||||
新增文件 `docs/sql/sys_menu_pilot_service.sql`,包含:
|
||||
- 一级菜单目录:中试服务管理(parent_id 指向 Web 端根菜单,即 `id=2`)
|
||||
- 6 个二级菜单项
|
||||
- 与路由 id 保持一致
|
||||
|
||||
---
|
||||
|
||||
## 6. UI 规范遵循
|
||||
|
||||
所有页面严格遵循 `docs/web-admin-ui-spec-fresh-mint.md` 和 AGENTS.md 中的 UI 约定:
|
||||
|
||||
- **颜色**: 主色 `#10b981`(Fresh Mint),不使用小程序颜色
|
||||
- **表单**: `label-position="top"`,使用 `.dialog-form-layout` + `.form-grid-2`
|
||||
- **弹窗**: `backdrop-filter: none`,`max-height: calc(100vh - 100px)` 只 body 滚动
|
||||
- **标题**: `panel-top` → `h2.title-bar-heading` 带竖线
|
||||
- **按钮**: 每行只有一个 `type="primary"`,工具栏"已选 N 项"在前
|
||||
- **排序字段**: 列表和弹窗中默认不展示 `order_num` 排序字段(按 AGENTS.md 约定)
|
||||
- **操作列**: `fixed="right"`,`min-width ≥ 260px`
|
||||
|
||||
---
|
||||
|
||||
## 7. 交付物清单
|
||||
|
||||
| # | 文件 | 操作 |
|
||||
|---|------|------|
|
||||
| 1 | `backend/app/models/models.py` | 追加 13 个 Model |
|
||||
| 2 | `backend/app/crud/pilot_service.py` | 新建 |
|
||||
| 3 | `backend/app/api/pilot_service.py` | 新建 |
|
||||
| 4 | `backend/app/api/__init__.py` | 追加 import + __all__ |
|
||||
| 5 | `backend/app/main.py` | 追加 include_router |
|
||||
| 6 | `web-admin/src/api/admin.ts` | 追加 API 函数 |
|
||||
| 7 | `web-admin/src/views/pilot-service/cell-product-type/index.vue` | 新建 |
|
||||
| 8 | `web-admin/src/views/pilot-service/cell-product-spec/index.vue` | 新建 |
|
||||
| 9 | `web-admin/src/views/pilot-service/cell-product-price/index.vue` | 新建 |
|
||||
| 10 | `web-admin/src/views/pilot-service/project-price-system/index.vue` | 新建 |
|
||||
| 11 | `web-admin/src/views/pilot-service/channel/index.vue` | 新建 |
|
||||
| 12 | `web-admin/src/views/pilot-service/checkup-institution/index.vue` | 新建 |
|
||||
| 13 | `web-admin/src/router/modules/sqxp.ts` | 追加路由 |
|
||||
| 14 | `docs/sql/sys_menu_pilot_service.sql` | 新建 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在范围内
|
||||
|
||||
以下内容不在本次实现中(后续模块需要):
|
||||
|
||||
- 2.3.7 渠道服务购买订单管理
|
||||
- 2.3.8 渠道服务购买订单管理(财务确认)
|
||||
- 2.3.9 中试服务单管理
|
||||
- 工作流集成
|
||||
- 数据权限(`apply_data_scope`)— 本次使用 `require_admin_or_super_admin`,后续按需添加
|
||||
@@ -0,0 +1,224 @@
|
||||
# 生产管理模块设计规格
|
||||
|
||||
## 概述
|
||||
实现需求文档 2.5 节中生产管理下的 7 个子模块(工艺路线配置暂不实现)。
|
||||
新增一级菜单【生产管理】,下设 7 个二级菜单。按依赖关系分 2 批实现。
|
||||
|
||||
## 范围
|
||||
| 批次 | 模块 | 需求章节 | 依赖 |
|
||||
|------|------|----------|------|
|
||||
| 第1批 | 耗材管理 | 2.5.1 | 无 |
|
||||
| 第1批 | 试剂管理 | 2.5.3 | 物品单位管理(已有) |
|
||||
| 第1批 | SOP文档 | 2.5.4 | 无(附件上传) |
|
||||
| 第1批 | 工艺工序分类 | 2.5.5 | 无 |
|
||||
| 第1批 | 工艺产出物类型 | 2.3.6 | 无 |
|
||||
| 第2批 | 耗材包管理 | 2.5.2 | 耗材管理 |
|
||||
| 第2批 | 工艺库管理 | 2.3.7 | 试剂、耗材、SOP、工序分类、产出物类型 |
|
||||
|
||||
**不在范围内**: 工艺路线配置[2.3.8] — 留待下次迭代。
|
||||
|
||||
## SQL 表(需新建 11 张)
|
||||
|
||||
### 第1批:单表模块(5 张)
|
||||
|
||||
#### 1. biz_consumable — 耗材
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| consumable_code | VARCHAR(64) NOT NULL | 耗材编码 |
|
||||
| consumable_name | VARCHAR(100) NOT NULL | 耗材名称 |
|
||||
| spec_model | VARCHAR(200) DEFAULT NULL | 规格型号 |
|
||||
| standard_usage | VARCHAR(100) DEFAULT NULL | 标准用量 |
|
||||
| description | VARCHAR(500) DEFAULT NULL | 描述 |
|
||||
| + 公共字段 | | tenant_no ~ del_flag |
|
||||
|
||||
#### 2. biz_reagent — 试剂
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| reagent_code | VARCHAR(64) NOT NULL | 试剂编码 |
|
||||
| reagent_name | VARCHAR(100) NOT NULL | 试剂名称 |
|
||||
| spec | VARCHAR(200) DEFAULT NULL | 规格 |
|
||||
| standard_usage | VARCHAR(100) DEFAULT NULL | 标准用量 |
|
||||
| unit | VARCHAR(64) DEFAULT NULL | 单位(下拉自物品单位管理) |
|
||||
| description | VARCHAR(500) DEFAULT NULL | 描述 |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 3. biz_sop_document — SOP文档
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| sop_code | VARCHAR(64) NOT NULL | SOP编号 |
|
||||
| sop_name | VARCHAR(200) NOT NULL | SOP文档名称 |
|
||||
| version_no | VARCHAR(50) DEFAULT NULL | 版本号 |
|
||||
| sop_attachments | VARCHAR(2000) DEFAULT NULL | SOP文档附件(逗号分隔URL) |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 4. biz_process_category — 工艺工序分类
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| category_code | VARCHAR(64) NOT NULL | 分类编码 |
|
||||
| category_name | VARCHAR(100) NOT NULL | 分类名称 |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 5. biz_output_type — 工艺产出物类型
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| type_code | VARCHAR(64) NOT NULL | 类型编码 |
|
||||
| type_name | VARCHAR(100) NOT NULL | 类型名称 |
|
||||
| + 公共字段 | | |
|
||||
|
||||
### 第2批:主表+子表模块(6 张)
|
||||
|
||||
#### 6. biz_consumable_pack — 耗材包主表
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| pack_code | VARCHAR(64) NOT NULL | 编号 |
|
||||
| pack_name | VARCHAR(100) NOT NULL | 名称 |
|
||||
| usage_scene | VARCHAR(200) DEFAULT NULL | 使用场景 |
|
||||
| pack_type | VARCHAR(20) DEFAULT NULL | 类型(bzhcb-标准耗材包、zdyhcb-自定义耗材包) |
|
||||
| status | CHAR(1) DEFAULT '1' | 状态(1-启用、0-停用) |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 7. biz_consumable_pack_item — 耗材包耗材明细
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| pack_id | VARCHAR(64) NOT NULL | 耗材包id |
|
||||
| consumable_code | VARCHAR(64) DEFAULT NULL | 耗材编码 |
|
||||
| consumable_name | VARCHAR(100) DEFAULT NULL | 耗材名称 |
|
||||
| spec_model | VARCHAR(200) DEFAULT NULL | 耗材规格型号 |
|
||||
| standard_usage | VARCHAR(100) DEFAULT NULL | 标准用量 |
|
||||
| remark | VARCHAR(512) DEFAULT NULL | 备注 |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 8. biz_process — 工艺库主表
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| process_code | VARCHAR(64) NOT NULL | 工序代码 |
|
||||
| category_id | VARCHAR(64) DEFAULT NULL | 工序分类id(下拉自工艺工序分类) |
|
||||
| process_name | VARCHAR(100) NOT NULL | 工序名称 |
|
||||
| description | VARCHAR(500) DEFAULT NULL | 描述 |
|
||||
| equipment_name | VARCHAR(200) DEFAULT NULL | 预计使用设备名称 |
|
||||
| estimated_duration | VARCHAR(100) DEFAULT NULL | 预估耗时 |
|
||||
| sop_ids | VARCHAR(1000) DEFAULT NULL | 关联SOP文档id(逗号分隔) |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 9. biz_process_reagent — 工艺-试剂关联
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| process_id | VARCHAR(64) NOT NULL | 工艺id |
|
||||
| reagent_code | VARCHAR(64) DEFAULT NULL | 试剂编码 |
|
||||
| reagent_name | VARCHAR(100) DEFAULT NULL | 试剂名称 |
|
||||
| spec | VARCHAR(200) DEFAULT NULL | 规格 |
|
||||
| standard_usage | VARCHAR(100) DEFAULT NULL | 标准用量 |
|
||||
| unit | VARCHAR(64) DEFAULT NULL | 单位 |
|
||||
| is_key_material | CHAR(1) DEFAULT '0' | 是否关键物料(0-否、1-是) |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 10. biz_process_consumable — 工艺-耗材关联
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| process_id | VARCHAR(64) NOT NULL | 工艺id |
|
||||
| consumable_code | VARCHAR(64) DEFAULT NULL | 耗材编码 |
|
||||
| consumable_name | VARCHAR(100) DEFAULT NULL | 耗材名称 |
|
||||
| spec_model | VARCHAR(200) DEFAULT NULL | 耗材规格型号 |
|
||||
| standard_usage | VARCHAR(100) DEFAULT NULL | 标准用量 |
|
||||
| is_required | CHAR(1) DEFAULT '1' | 是否必需(1-必须、0-可选) |
|
||||
| remark | VARCHAR(512) DEFAULT NULL | 备注 |
|
||||
| + 公共字段 | | |
|
||||
|
||||
#### 11. biz_process_output — 工艺-产出关联
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR(64) PK | UUID |
|
||||
| process_id | VARCHAR(64) NOT NULL | 工艺id |
|
||||
| output_type_id | VARCHAR(64) DEFAULT NULL | 产出类型(下拉自工艺产出物类型) |
|
||||
| output_name | VARCHAR(200) DEFAULT NULL | 产出物名称 |
|
||||
| output_barcode | VARCHAR(200) DEFAULT NULL | 产出物条码(生产时才有数据) |
|
||||
| + 公共字段 | | |
|
||||
|
||||
## 模块设计
|
||||
|
||||
### 模块 1-5: 耗材/试剂/SOP/工序分类/产出物类型(简单 CRUD)
|
||||
- **类型**: 标准列表模式
|
||||
- **后端**: Model + CRUD + API 路由(完全参照 `item_unit.py` 模式)
|
||||
- **前端**: 参照 `system/unit/index.vue` 标准列表
|
||||
- **特殊**: 试剂管理的"单位"字段下拉数据从物品单位管理获取;SOP文档需要附件上传
|
||||
|
||||
### 模块 6: 耗材包管理(主表+明细)
|
||||
- **类型**: 标准列表 + 弹窗内明细表格
|
||||
- **后端**: 主表 CRUD + 明细子表 CRUD(级联创建/更新/删除)
|
||||
- **前端**: 列表参照 `unit/index.vue`;新增/编辑弹窗中嵌入耗材明细表格,支持:
|
||||
- 从耗材库逐条添加(下拉选择耗材后自动带入编码/名称/规格/用量)
|
||||
- 行内编辑标准用量
|
||||
- 行内删除
|
||||
- **API**: `/web/admin/consumable-packs` + `/web/admin/consumable-packs/{id}` (含明细一起提交)
|
||||
|
||||
### 模块 7: 工艺库管理(主表+3个关联子表)
|
||||
- **类型**: 标准列表 + Tab式编辑弹窗
|
||||
- **后端**: 主表 CRUD + 3 个子表 CRUD(级联操作)
|
||||
- **前端**: 列表页标准列表;新增/编辑弹窗分 4 个 Tab:
|
||||
- Tab1「基本信息」: 工序代码、工序分类(下拉)、工序名称、描述、设备名称、预估耗时、关联SOP(多选下拉)
|
||||
- Tab2「试剂关联」: 表格形式,下拉选试剂后自动带入编码/名称/规格/用量/单位,可改标准用量,标记是否关键物料
|
||||
- Tab3「耗材关联」: 表格形式,支持从耗材包批量导入或从耗材库逐条添加;可改标准用量,标记是否必需
|
||||
- Tab4「产出物」: 表格形式,下拉选产出类型 + 填产出物名称
|
||||
- **API**: `/web/admin/processes` + `/web/admin/processes/{id}` (含所有子表一起提交)
|
||||
|
||||
## 路由与菜单
|
||||
|
||||
### 前端路由
|
||||
在 `sqxp.ts` 新增顶级路由组:
|
||||
```
|
||||
/production
|
||||
/production/consumable 耗材管理
|
||||
/production/reagent 试剂管理
|
||||
/production/sop SOP文档
|
||||
/production/process-category 工艺工序分类
|
||||
/production/output-type 工艺产出物类型
|
||||
/production/consumable-pack 耗材包管理
|
||||
/production/process 工艺库管理
|
||||
```
|
||||
|
||||
### 后端 API
|
||||
统一路由前缀 `/web/admin`,新增以下端点:
|
||||
```
|
||||
GET/POST /consumables 耗材列表/创建
|
||||
GET/PUT/DELETE /consumables/{id} 耗材详情/更新/删除
|
||||
GET/POST /reagents 试剂列表/创建
|
||||
GET/PUT/DELETE /reagents/{id} 试剂详情/更新/删除
|
||||
GET/POST /sop-documents SOP列表/创建
|
||||
GET/PUT/DELETE /sop-documents/{id} SOP详情/更新/删除
|
||||
GET/POST /process-categories 工序分类列表/创建
|
||||
GET/PUT/DELETE /process-categories/{id} 工序分类详情/更新/删除
|
||||
GET/POST /output-types 产出物类型列表/创建
|
||||
GET/PUT/DELETE /output-types/{id} 产出物类型详情/更新/删除
|
||||
GET/POST /consumable-packs 耗材包列表/创建
|
||||
GET/PUT/DELETE /consumable-packs/{id} 耗材包详情/更新/删除
|
||||
GET/POST /processes 工艺库列表/创建
|
||||
GET/PUT/DELETE /processes/{id} 工艺库详情/更新/删除
|
||||
```
|
||||
|
||||
### 菜单 SQL
|
||||
在 `sys_menu` 中新增生产管理顶级菜单(parent_id 指向 Web 端菜单根节点)及 7 个子菜单项。
|
||||
|
||||
## 技术规范
|
||||
- 后端模式:flat 函数式 CRUD(参照 `app/crud/item_unit.py`),不用 service 层
|
||||
- 前端模式:API 函数加入 `production.ts`,路由加入 `sqxp.ts`
|
||||
- 所有查询带 `tenant_no` 过滤,删除为软删除
|
||||
- Fresh Mint 配色 (`--primary #10b981`)
|
||||
- 附件使用 MinIO,多附件逗号分隔 URL
|
||||
- 编码字段需做唯一性校验(同 tenant_no + del_flag=0 下)
|
||||
|
||||
## 实施顺序
|
||||
1. **第1批**(5 个简单模块,可并行):
|
||||
- SQL DDL → Model → CRUD → API → 注册 → 前端 API → 路由 → 页面
|
||||
2. **第2批**(2 个复杂模块,串行):
|
||||
- 耗材包管理 → 工艺库管理
|
||||
3. **菜单 SQL**:与第1批一起执行
|
||||
@@ -0,0 +1,231 @@
|
||||
# 科研捐赠管理模块设计文档
|
||||
|
||||
> 日期:2026-05-17
|
||||
> 方案:C — 独立业务 + 共享基础数据
|
||||
> 状态:已确认
|
||||
|
||||
## 背景与需求
|
||||
|
||||
客户现有「细胞存储管理」业务,现需新增「科研捐赠」业务。两者关系:
|
||||
|
||||
- **独立新建**:科研捐赠可以直接创建
|
||||
- **双向转化**:细胞存储协议可「转科研」→ 科研捐赠;科研捐赠可「转存储」→ 细胞存储协议
|
||||
- **核心区别**:
|
||||
1. 科研捐赠不收费(无缴费相关流程)
|
||||
2. 科研捐赠必须上传知情同意书附件
|
||||
- **相同部分**:当事人信息、采集信息、样本信息、审核流程、状态生命周期
|
||||
|
||||
## 方案选型
|
||||
|
||||
| 方案 | 描述 | 结论 |
|
||||
|------|------|------|
|
||||
| A. 完全复制+裁剪 | Clone 细胞存储全部代码,删缴费/储存证书 | 代码重复严重 |
|
||||
| B. 共享表+类型区分 | 同一套表,用 business_type 区分 | 耦合高,维护难 |
|
||||
| **C. 独立业务+共享基础数据** | 独立表和 CRUD,复用基础数据和前端组件 | **已选择** |
|
||||
|
||||
选择理由:符合项目现有模式(每业务独立路由/CRUD),风险最低,独立演进。
|
||||
|
||||
---
|
||||
|
||||
## Section 1:数据库设计
|
||||
|
||||
### 新建表(6 张)
|
||||
|
||||
#### `biz_research_donation` — 主表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | VARCHAR(64) UUID | 主键 |
|
||||
| `donation_no` | VARCHAR(64) | 捐赠协议编号 |
|
||||
| `status` | VARCHAR(20) | 状态:dsx/sxz/ygq/yzf/zckc |
|
||||
| `donation_date` | DATE | 捐赠日期 |
|
||||
| `source_type` | VARCHAR(20) | 来源类型:`direct`(独立新建)/ `from_storage`(从存储转来) |
|
||||
| `source_protocol_id` | VARCHAR(64) | 来源协议 ID(from_storage 时填源细胞存储协议 ID) |
|
||||
| `consent_form_urls` | TEXT | 知情同意书附件 URL(逗号分隔,必填) |
|
||||
| `attachment_urls` | TEXT | 其他附件 URL(逗号分隔,可选) |
|
||||
| `remark` | VARCHAR(500) | 备注 |
|
||||
| `tenant_no` | VARCHAR(64) | 租户编号 |
|
||||
| `order_num` | INT | 排序号 |
|
||||
| `create_by` | VARCHAR(64) | 创建人 |
|
||||
| `create_dept_id` | VARCHAR(64) | 创建部门 |
|
||||
| `create_time` | DATETIME | 创建时间 |
|
||||
| `update_by` | VARCHAR(64) | 更新人 |
|
||||
| `update_time` | DATETIME | 更新时间 |
|
||||
| `del_flag` | CHAR(1) DEFAULT '0' | 删除标记 |
|
||||
|
||||
#### `biz_research_donation_audit` — 审核记录
|
||||
|
||||
结构与 `biz_protocol_audit` 一致:审核状态(shtg/shth)+ 备注 + 审核人 + 审核时间。
|
||||
|
||||
#### `biz_research_donation_party` — 当事人信息
|
||||
|
||||
结构与 `biz_protocol_party_customer` 一致:母亲/父亲/新生儿信息。
|
||||
|
||||
#### `biz_research_donation_collection` — 采集信息
|
||||
|
||||
结构与 `biz_protocol_collection_info` 一致:区域/医院/采集人/时间。
|
||||
|
||||
#### `biz_research_donation_sample` — 样本信息
|
||||
|
||||
参照 `biz_protocol_sample`,**去掉**金额/费用相关字段,保留:样本类型、编码、数量、储存年限。
|
||||
|
||||
#### `biz_research_donation_sample_transport` — 样本运输信息
|
||||
|
||||
结构与 `biz_protocol_sample_transport` 一致。
|
||||
|
||||
### 不新建的表
|
||||
|
||||
| 细胞存储表 | 原因 |
|
||||
|---|---|
|
||||
| `biz_protocol_payment` | 科研捐赠不收费 |
|
||||
| `biz_protocol_payment_detail` | 科研捐赠不收费 |
|
||||
| `biz_protocol_storage_cert` | 科研捐赠无储存证书 |
|
||||
|
||||
### 复用的基础数据表(不新建)
|
||||
|
||||
- `biz_customer` — 客户主数据
|
||||
- `biz_cell_sample_type` — 样本类型
|
||||
- `biz_collection_region` / `biz_collection_hospital` — 采集区域/医院
|
||||
- `biz_sample_code` — 样本编码池
|
||||
- `biz_attachment` — 通用附件
|
||||
|
||||
### 状态生命周期
|
||||
|
||||
```
|
||||
科研捐赠:dsx(待生效) → sxz(生效中) → ygq(已过期) / yzf(已作废) / zckc(转细胞存储)
|
||||
细胞存储:dsx(待生效) → sxz(生效中) → ygq(已过期) / yzf(已作废) / zky(转科研)
|
||||
```
|
||||
|
||||
### 双向转化机制
|
||||
|
||||
- **细胞存储 → 科研捐赠**:细胞存储协议状态变为 `zky`,新建科研捐赠记录(`source_type=from_storage`, `source_protocol_id=源协议ID`)
|
||||
- **科研捐赠 → 细胞存储**:科研捐赠状态变为 `zckc`,新建细胞存储协议记录(缴费信息留空待补填),科研捐赠记录保留关联
|
||||
|
||||
---
|
||||
|
||||
## Section 2:后端架构
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── api/
|
||||
│ ├── research_donation.py # 新建:科研捐赠 API 路由
|
||||
│ └── __init__.py # 注册新路由
|
||||
├── crud/
|
||||
│ └── research_donation.py # 新建:科研捐赠 CRUD/业务逻辑
|
||||
├── models/
|
||||
│ └── models.py # 新增 6 个 Model 类
|
||||
├── schemas/
|
||||
│ └── research_donation.py # 新建:Pydantic schemas
|
||||
└── main.py # 注册新路由
|
||||
```
|
||||
|
||||
### API 路由
|
||||
|
||||
前缀:`/web/admin/research-donations`,tag `"科研捐赠管理"`
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|---|---|---|
|
||||
| `/` | GET | 列表查询(分页、关键字、状态筛选) |
|
||||
| `/` | POST | 新建科研捐赠 |
|
||||
| `/{id}` | GET | 详情(含所有子资源) |
|
||||
| `/{id}` | PUT | 更新 |
|
||||
| `/{id}` | DELETE | 删除(软删除) |
|
||||
| `/batch-delete` | POST | 批量删除 |
|
||||
| `/audit/list` | GET | 审核列表 |
|
||||
| `/audit/submit` | POST | 提交审核(单个/批量) |
|
||||
| `/status-change` | POST | 状态变更(生效/过期/作废/转存储) |
|
||||
| `/convert-from-storage` | POST | 从细胞存储协议转化 |
|
||||
| `/convert-to-storage` | POST | 转为细胞存储 |
|
||||
|
||||
### CRUD 层
|
||||
|
||||
参照 `collection_protocol.py` 模式,简化(无缴费逻辑):
|
||||
|
||||
- 子资源整体 upsert
|
||||
- 审核逻辑(shtg/shth + remark)
|
||||
- 状态机校验
|
||||
- 转化逻辑(双向数据复制 + 关联)
|
||||
- 租户隔离 + 数据权限过滤
|
||||
|
||||
### Schemas
|
||||
|
||||
- `ResearchDonationCreate` / `ResearchDonationUpdate`
|
||||
- `ResearchDonationResponse` / `ResearchDonationListResponse`
|
||||
- `ResearchDonationAuditSubmit`
|
||||
- `ResearchDonationStatusChange`
|
||||
- `ConvertFromStorageRequest` / `ConvertToStorageRequest`
|
||||
|
||||
### 代码复用策略
|
||||
|
||||
- **复用**:基础数据查询函数、附件处理工具、字段命名转换工具
|
||||
- **不复用**:CRUD 代码(独立文件,避免耦合)
|
||||
|
||||
---
|
||||
|
||||
## Section 3:前端结构
|
||||
|
||||
### 路由注册(`sqxp.ts`)
|
||||
|
||||
```
|
||||
科研捐赠管理 (id: 6000...)
|
||||
├── /research-donation/list → 捐赠协议单管理
|
||||
├── /research-donation/audit → 捐赠协议审核
|
||||
```
|
||||
|
||||
### 页面与组件
|
||||
|
||||
```
|
||||
web-admin/src/
|
||||
├── views/research-donation/
|
||||
│ ├── list/
|
||||
│ │ ├── index.vue # 列表页
|
||||
│ │ ├── DonationFormDialog.vue # 新增/编辑弹窗(5 Tab)
|
||||
│ │ ├── DonationViewDialog.vue # 查看详情弹窗
|
||||
│ │ ├── DonationAuditDialog.vue # 审核弹窗
|
||||
│ │ └── DonationStatusChangeDialog.vue # 状态变更弹窗(含转存储)
|
||||
│ └── audit/
|
||||
│ └── index.vue # 审核管理页
|
||||
├── api/
|
||||
│ └── research-donation.ts # API 模块
|
||||
└── components/ # 从细胞存储提取的公共组件
|
||||
├── party-info/ # 当事人信息(Form + View)
|
||||
├── collection-info/ # 采集信息(Form + View)
|
||||
└── sample-info/ # 样本信息(Form + View,捐赠版去金额)
|
||||
```
|
||||
|
||||
### 捐赠表单弹窗(5 个 Tab)
|
||||
|
||||
| Tab | 说明 | 来源 |
|
||||
|---|---|---|
|
||||
| 基本信息 | 协议编号、状态、日期、来源类型、备注 | 新写 |
|
||||
| 当事人信息 | 母亲/父亲/新生儿 | 提取公共组件 `PartyInfoForm` |
|
||||
| 采集信息 | 区域/医院/采集人/时间 | 提取公共组件 `CollectionInfoForm` |
|
||||
| 样本信息 | 样本类型/编码/数量/储存年限(无金额) | 提取公共组件 `SampleInfoForm` |
|
||||
| 附件 | 知情同意书(必传)+ 其他附件 | 新写 |
|
||||
|
||||
### 知情同意书上传
|
||||
|
||||
在「附件」Tab 中分两个区域:
|
||||
1. **知情同意书**(必传)— 红色 `*` 标注,提交时校验至少 1 份
|
||||
2. **其他附件** — 可选
|
||||
|
||||
### 细胞存储端最小改动
|
||||
|
||||
- `ProtocolStatusChangeDialog.vue` 增加 `zky`(转科研)操作
|
||||
- 列表页状态筛选增加「转科研」选项
|
||||
- 不改表结构,不改现有 CRUD 逻辑
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序建议
|
||||
|
||||
1. SQL 建表脚本 → `docs/sql/`
|
||||
2. 后端 Model + Schema
|
||||
3. 后端 CRUD(子资源 upsert、审核、状态机)
|
||||
4. 后端 API 路由 + 注册
|
||||
5. 提取前端公共组件(当事人/采集/样本)
|
||||
6. 前端页面(列表 → 表单弹窗 → 查看弹窗 → 审核弹窗 → 状态变更)
|
||||
7. 双向转化功能(转科研 + 转存储)
|
||||
8. 联调测试
|
||||
@@ -0,0 +1,240 @@
|
||||
# 体检机构项目套餐设计文档
|
||||
|
||||
> 日期:2026-05-18
|
||||
> 状态:已确认
|
||||
> 父模块:中试服务管理 §2.3.6 体检机构及项目价格
|
||||
> 需求来源:`docs/中科兴皖需求V0.3.md` §2.3.6(增强)、探索结论 2026-05-18
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
运营在维护各体检机构的**原子项目**后,常需将多项组合成固定搭配(如「基础套餐」「全面套餐」),供后续业务人员一键选用,减少逐条勾选与漏项。
|
||||
|
||||
**目标**
|
||||
|
||||
- 在机构维度维护「套餐」:勾选多个本机构项目,可调整套餐内各行费用。
|
||||
- **套餐总价 = 所含项目费用之和**(不单独维护打包价,口径简单、易解释)。
|
||||
- 业务落单(中试申请单体检预约等)时**展开为明细行**写入现有 `biz_pilot_checkup_reserve_item`,不引入新的结算实体。
|
||||
|
||||
**非目标(首期不做)**
|
||||
|
||||
- 跨机构共用套餐模板库
|
||||
- 套餐固定优惠价(与明细之和不一致)
|
||||
- 仅保存套餐头、不展开明细的预约模型
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认业务规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 套餐总价 | **始终**为明细 `project_fee` 之和;**不存**套餐头「一口价」字段 |
|
||||
| 明细价格 | 从机构项目带入默认费用后,**可在套餐内单独修改**;修改仅影响该套餐行,**不回写**机构项目主数据 |
|
||||
| 套餐归属 | 仅属于一个 `checkup_institution_id`,不跨机构 |
|
||||
| 项目引用 | 明细行关联 `checkup_institution_project_id`;保存时校验项目属于同一机构且未删除 |
|
||||
| 停用项目 | 套餐保存/启用时:若含 `status=0` 的项目,**禁止保存或禁止启用**(二选一实现,推荐禁止保存含停用项) |
|
||||
| 历史业务单 | 下游预约/实际明细为快照(名称+费用),套餐后续调价**不影响**已生成单据 |
|
||||
| 名称快照 | 明细行冗余 `project_name`(与 2.3.4 项目价格体系明细一致),避免主数据改名后历史套餐展示歧义 |
|
||||
| **套餐编码** | **必填**;同一机构下未删除(`del_flag=0`)的套餐编码**唯一**(应用层校验) |
|
||||
| **删除机构项目** | **允许软删除**,不因被套餐引用而拦截;**不级联**删除或修改套餐明细行 |
|
||||
| 删除后展示 | 套餐明细仍按行内 `project_name` / `project_fee` 快照展示;源项目已删时 UI 标注「源项目已删除」,**不影响历史查看与合计** |
|
||||
|
||||
### 2.1 机构项目软删除与套餐的关系
|
||||
|
||||
```
|
||||
运营删除机构项目(del_flag=1)
|
||||
│
|
||||
├─► 套餐明细行:保留,不自动移除
|
||||
│ 展示:project_name(快照)+ 可选标签「源项目已删除」
|
||||
│ 合计:仍计入该行 project_fee(套餐定义不变)
|
||||
│
|
||||
├─► 编辑套餐:不可再「添加」已删除/停用的源项目;已有行可删除或改价
|
||||
│
|
||||
└─► Phase 2 从套餐带入预约:仅展开源项目仍有效(del_flag=0 且 status=1)的行;
|
||||
已删除源项目行跳过(快照仅用于套餐档案展示)
|
||||
```
|
||||
|
||||
与中试单预约/实际明细一致:业务单落库为快照,**机构项目删除不影响已生成业务单展示**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 ER 关系
|
||||
|
||||
```
|
||||
biz_checkup_institution
|
||||
│
|
||||
├──1:N── biz_checkup_institution_project (原子项目,已有)
|
||||
│
|
||||
└──1:N── biz_checkup_institution_package (套餐头,新增)
|
||||
│
|
||||
└──1:N── biz_checkup_institution_package_item (套餐明细,新增)
|
||||
│
|
||||
└──N:1── biz_checkup_institution_project
|
||||
```
|
||||
|
||||
### 3.2 表:`biz_checkup_institution_package`(套餐头)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | VARCHAR(64) | 主键 UUID |
|
||||
| `checkup_institution_id` | VARCHAR(64) | 所属体检机构 |
|
||||
| `package_name` | VARCHAR(200) | 套餐名称(必填) |
|
||||
| `package_code` | VARCHAR(64) | 套餐编码(**必填**,机构内 `del_flag=0` 唯一) |
|
||||
| `status` | CHAR(1) | `1` 正常 / `0` 停用 |
|
||||
| `tenant_no` … `del_flag` | — | 公共字段(见 database-conventions) |
|
||||
|
||||
**不建字段**:`package_fee` / `total_fee`(总价由明细汇总计算)。
|
||||
|
||||
### 3.3 表:`biz_checkup_institution_package_item`(套餐明细)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | VARCHAR(64) | 主键 |
|
||||
| `package_id` | VARCHAR(64) | 套餐头 ID |
|
||||
| `checkup_institution_project_id` | VARCHAR(64) | 来源机构项目 ID |
|
||||
| `project_name` | VARCHAR(200) | 项目名称快照(从项目带入,可展示用) |
|
||||
| `project_fee` | DECIMAL(18,2) | **套餐内**项目费用(元),可编辑 |
|
||||
| `tenant_no` … `del_flag` | — | 公共字段 |
|
||||
|
||||
**约束**
|
||||
|
||||
- 同一 `package_id` 下 `checkup_institution_project_id` 不重复。
|
||||
- `project_fee` ≥ 0;允许为 NULL 时按 0 参与合计(建议表单必填 ≥0)。
|
||||
|
||||
### 3.4 API 派生字段
|
||||
|
||||
列表/详情响应增加只读字段(不落库):
|
||||
|
||||
```json
|
||||
{
|
||||
"totalFee": 1280.00,
|
||||
"itemCount": 5
|
||||
}
|
||||
```
|
||||
|
||||
`totalFee = SUM(package_item.project_fee)`,后端计算返回。
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端设计
|
||||
|
||||
沿用 `pilot_service.py` + `pilot_service.py` CRUD 分段模式。
|
||||
|
||||
### 4.1 端点
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/checkup-institutions/{institution_id}/packages` | 机构下套餐列表(含 `totalFee`、`itemCount`) |
|
||||
| POST | `/checkup-institutions/{institution_id}/packages` | 新增套餐(Body 含 `items[]`) |
|
||||
| GET | `/checkup-institution-packages/{package_id}` | 套餐详情 + 明细 |
|
||||
| PUT | `/checkup-institution-packages/{package_id}` | 更新套餐头 + 全量替换明细(与 2.3.4 主子表保存方式一致) |
|
||||
| DELETE | `/checkup-institution-packages/{package_id}` | 软删除套餐及明细 |
|
||||
|
||||
权限、租户隔离、数据权限与现有中试服务接口一致。
|
||||
|
||||
### 4.2 保存逻辑(要点)
|
||||
|
||||
1. 校验 `package_code`、`package_name` 非空;`package_code` 在同机构未删除套餐中唯一。
|
||||
2. 校验 `items` 非空(至少 1 项)。
|
||||
3. **新增/全量替换明细**时:每项 `checkup_institution_project_id` 须属于当前机构、`del_flag=0`、`status=1`。
|
||||
4. **更新且明细含历史行**(源项目已软删):允许保留该行(`project_name` / `project_fee` 以 Body 或库内快照为准),不允许新增指向已删项目的行。
|
||||
5. 写入明细时:`project_name` 默认取机构项目当前名称;`project_fee` 取 Body 传入值。
|
||||
6. 不写入、不校验套餐头总价字段。
|
||||
|
||||
### 4.3 机构项目删除(`soft_delete_checkup_institution_project`)
|
||||
|
||||
- **不检查**套餐引用,直接软删除机构项目。
|
||||
- **不修改** `biz_checkup_institution_package_item` 任意字段。
|
||||
- 列表/详情 join 源项目时附带 `sourceProjectDeleted: true`(源 `del_flag=1` 或不存在时),供前端展示标签。
|
||||
|
||||
### 4.4 Model
|
||||
|
||||
在 `backend/app/models/models.py` 追加:
|
||||
|
||||
- `BizCheckupInstitutionPackage`
|
||||
- `BizCheckupInstitutionPackageItem`
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端设计(web-admin)
|
||||
|
||||
### 5.1 入口
|
||||
|
||||
在 **体检机构及项目价格** 页(`checkup-institution/index.vue`)的「项目价格配置」弹窗内增加 Tab 或并列入口:
|
||||
|
||||
- **Tab 1:项目列表**(现有)
|
||||
- **Tab 2:套餐管理**(新增)
|
||||
|
||||
也可采用「套餐配置」独立按钮 + 弹窗,与「项目价格配置」同级;以不挤占单弹窗高度为准。
|
||||
|
||||
### 5.2 套餐列表
|
||||
|
||||
列:序号、套餐名称、套餐编码、项目数、**合计费用(元)**、状态、备注、操作(编辑/删除)。
|
||||
|
||||
合计费用列展示 API 返回的 `totalFee`(只读)。
|
||||
|
||||
### 5.3 套餐新增/编辑弹窗
|
||||
|
||||
**头部**:套餐名称(必填)、**套餐编码(必填)**、状态(正常/停用)、备注。
|
||||
|
||||
**明细区**(可编辑表格,对齐 `project-price-system` 明细交互):
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 添加项目 | 从当前机构「正常」状态项目中多选/逐条添加 |
|
||||
| 删除行 | 移除明细 |
|
||||
| 项目名称 | 只读展示(快照);源项目已删时后缀 muted 文案「源项目已删除」 |
|
||||
| 项目费用(元) | **可编辑** `el-input-number`,默认 = 机构项目当前 `projectFee` |
|
||||
| 合计 | 弹窗底部展示「套餐合计:¥ xxx.xx」= 各行费用实时求和 |
|
||||
|
||||
保存时提交 `items: [{ checkupInstitutionProjectId, projectName, projectFee }, ...]`,不传 `totalFee`。
|
||||
|
||||
### 5.4 UI 规范
|
||||
|
||||
- 遵循 Fresh Mint 与 `dialog-form-layout`、`form-grid-2`、列表工具栏等既有约定。
|
||||
- 树/路径无涉;明细勾选来自 flat 项目列表。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与下游业务对接(Phase 2)
|
||||
|
||||
中试申请单体检模块(`biz_pilot_checkup` + `reserve_item`)实现时:
|
||||
|
||||
1. 选择机构后,提供 **「从套餐添加」**:选套餐 → 将有效 `package_item` 展开为预约明细行(`project_name` + `project_fee`)。
|
||||
2. **有效行**:源机构项目 `del_flag=0` 且 `status=1`;源已删/停用的套餐行**跳过**,不写入预约明细(套餐档案仍完整展示)。
|
||||
3. 展开后各行仍可在预约单内微调(与 V0.3 明细模型一致)。
|
||||
4. 可选:明细行增加 `source_package_id` 字段便于统计(**非首期必需**)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 实现分期
|
||||
|
||||
| 阶段 | 范围 |
|
||||
|------|------|
|
||||
| **Phase 1** | 表结构 + 后端 CRUD + web-admin 套餐维护(本文 §3–5) |
|
||||
| **Phase 2** | 中试申请单体检预约「从套餐添加」展开明细 |
|
||||
|
||||
---
|
||||
|
||||
## 8. SQL 增量
|
||||
|
||||
见 `docs/sql/checkup_institution_package.sql`(`CREATE TABLE` 两张 + 索引)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 与 V0.3 / 原设计的关系
|
||||
|
||||
- V0.3 §2.3.6 仅定义机构级原子项目;本设计为**已确认增强**,不改变下游明细表结构。
|
||||
- 模式对齐 §2.3.4「细胞产品项目价格体系」主子表,降低实现与认知成本。
|
||||
|
||||
---
|
||||
|
||||
## 10. 产品确认记录(2026-05-18 补充)
|
||||
|
||||
| 议题 | 结论 |
|
||||
|------|------|
|
||||
| 套餐编码 | **必填**;机构内未删除套餐编码唯一 |
|
||||
| 删除被套餐引用的机构项目 | **允许软删除**;套餐明细**不自动移除**;靠快照保证历史展示;业务带入时跳过无效源项目 |
|
||||
@@ -80,7 +80,7 @@
|
||||
const panelTitleClassList = panelTitleIconByText.map(([, cls]) => cls)
|
||||
const panelTitleBarClass = 'title-bar-heading'
|
||||
const panelTitleBarTextRules: RegExp[] = [
|
||||
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表)/
|
||||
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表|类型列表|规格列表|价格列表|体系列表|渠道列表|机构列表|耗材列表|试剂列表|SOP列表|工艺列表|耗材包列表)/
|
||||
]
|
||||
|
||||
const refreshPanelTitleIcons = () => {
|
||||
|
||||
@@ -1368,3 +1368,362 @@ export function fetchUpdateExhibitionAppointment(id: string, data: Record<string
|
||||
export function fetchDeleteExhibitionAppointment(id: string) {
|
||||
return request.del({ url: `/web/admin/exhibition-appointments/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 细胞产品类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchCellProductTypeList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get({
|
||||
url: '/web/admin/cell-product-types',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateCellProductType(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/cell-product-types', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateCellProductType(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/cell-product-types/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCellProductType(id: string) {
|
||||
return request.del({ url: `/web/admin/cell-product-types/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 细胞产品规格
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchCellProductSpecList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get({
|
||||
url: '/web/admin/cell-product-specs',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateCellProductSpec(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/cell-product-specs', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateCellProductSpec(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/cell-product-specs/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCellProductSpec(id: string) {
|
||||
return request.del({ url: `/web/admin/cell-product-specs/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 细胞产品价格
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchCellProductPriceList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
cellProductTypeId?: string
|
||||
}) {
|
||||
return request.get({
|
||||
url: '/web/admin/cell-product-prices',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateCellProductPrice(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/cell-product-prices', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateCellProductPrice(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/cell-product-prices/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCellProductPrice(id: string) {
|
||||
return request.del({ url: `/web/admin/cell-product-prices/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 项目价格体系(主表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchProjectSystemList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get({
|
||||
url: '/web/admin/project-price-systems',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateProjectSystem(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/project-price-systems', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateProjectSystem(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/project-price-systems/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteProjectSystem(id: string) {
|
||||
return request.del({ url: `/web/admin/project-price-systems/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 项目价格体系明细(子表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchProjectSystemItems(systemId: string) {
|
||||
return request.get({ url: `/web/admin/project-price-systems/${systemId}/items` })
|
||||
}
|
||||
|
||||
export function fetchCreateProjectSystemItem(systemId: string, data: Record<string, any>) {
|
||||
return request.post({ url: `/web/admin/project-price-systems/${systemId}/items`, params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateProjectSystemItem(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/project-system-items/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteProjectSystemItem(id: string) {
|
||||
return request.del({ url: `/web/admin/project-system-items/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 渠道信息(主表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchChannelList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get({
|
||||
url: '/web/admin/channels',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateChannel(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/channels', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateChannel(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/channels/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteChannel(id: string) {
|
||||
return request.del({ url: `/web/admin/channels/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 渠道开票信息(子表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchChannelInvoices(channelId: string) {
|
||||
return request.get({ url: `/web/admin/channels/${channelId}/invoices` })
|
||||
}
|
||||
|
||||
export function fetchCreateChannelInvoice(channelId: string, data: Record<string, any>) {
|
||||
return request.post({ url: `/web/admin/channels/${channelId}/invoices`, params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateChannelInvoice(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/channel-invoices/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteChannelInvoice(id: string) {
|
||||
return request.del({ url: `/web/admin/channel-invoices/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 渠道邮寄地址(子表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchChannelMailAddresses(channelId: string) {
|
||||
return request.get({ url: `/web/admin/channels/${channelId}/mail-addresses` })
|
||||
}
|
||||
|
||||
export function fetchCreateChannelMailAddress(channelId: string, data: Record<string, any>) {
|
||||
return request.post({ url: `/web/admin/channels/${channelId}/mail-addresses`, params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateChannelMailAddress(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/channel-mail-addresses/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteChannelMailAddress(id: string) {
|
||||
return request.del({ url: `/web/admin/channel-mail-addresses/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 渠道协议(子表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchChannelAgreements(channelId: string) {
|
||||
return request.get({ url: `/web/admin/channels/${channelId}/agreements` })
|
||||
}
|
||||
|
||||
export function fetchCreateChannelAgreement(channelId: string, data: Record<string, any>) {
|
||||
return request.post({ url: `/web/admin/channels/${channelId}/agreements`, params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateChannelAgreement(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/channel-agreements/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteChannelAgreement(id: string) {
|
||||
return request.del({ url: `/web/admin/channel-agreements/${id}` })
|
||||
}
|
||||
|
||||
export function fetchActivateChannelAgreement(id: string) {
|
||||
return request.put({ url: `/web/admin/channel-agreements/${id}/activate` })
|
||||
}
|
||||
|
||||
export function fetchVoidChannelAgreement(id: string) {
|
||||
return request.put({ url: `/web/admin/channel-agreements/${id}/void` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 渠道协议产品价格(子表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchChannelAgreementPrices(agreementId: string) {
|
||||
return request.get({ url: `/web/admin/channel-agreements/${agreementId}/product-prices` })
|
||||
}
|
||||
|
||||
export function fetchCreateChannelAgreementPrice(agreementId: string, data: Record<string, any>) {
|
||||
return request.post({
|
||||
url: `/web/admin/channel-agreements/${agreementId}/product-prices`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateChannelAgreementPrice(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/channel-agreement-product-prices/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteChannelAgreementPrice(id: string) {
|
||||
return request.del({ url: `/web/admin/channel-agreement-product-prices/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 体检机构(主表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchCheckupInstitutionList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get({
|
||||
url: '/web/admin/checkup-institutions',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateCheckupInstitution(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/checkup-institutions', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateCheckupInstitution(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/checkup-institutions/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCheckupInstitution(id: string) {
|
||||
return request.del({ url: `/web/admin/checkup-institutions/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 体检机构开票信息(子表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchCheckupInstitutionInvoices(institutionId: string) {
|
||||
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/invoices` })
|
||||
}
|
||||
|
||||
export function fetchCreateCheckupInstitutionInvoice(
|
||||
institutionId: string,
|
||||
data: Record<string, any>
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/checkup-institutions/${institutionId}/invoices`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateCheckupInstitutionInvoice(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/checkup-institution-invoices/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCheckupInstitutionInvoice(id: string) {
|
||||
return request.del({ url: `/web/admin/checkup-institution-invoices/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 体检机构项目及价格(子表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchCheckupInstitutionProjects(institutionId: string) {
|
||||
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/projects` })
|
||||
}
|
||||
|
||||
export function fetchCreateCheckupInstitutionProject(
|
||||
institutionId: string,
|
||||
data: Record<string, any>
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/checkup-institutions/${institutionId}/projects`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateCheckupInstitutionProject(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/checkup-institution-projects/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCheckupInstitutionProject(id: string) {
|
||||
return request.del({ url: `/web/admin/checkup-institution-projects/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 中试服务管理 — 体检机构项目套餐
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchCheckupInstitutionPackages(institutionId: string) {
|
||||
return request.get({ url: `/web/admin/checkup-institutions/${institutionId}/packages` })
|
||||
}
|
||||
|
||||
export function fetchCheckupInstitutionPackageDetail(packageId: string) {
|
||||
return request.get({ url: `/web/admin/checkup-institution-packages/${packageId}` })
|
||||
}
|
||||
|
||||
export function fetchCreateCheckupInstitutionPackage(
|
||||
institutionId: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/checkup-institutions/${institutionId}/packages`,
|
||||
params: data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateCheckupInstitutionPackage(
|
||||
packageId: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
return request.put({ url: `/web/admin/checkup-institution-packages/${packageId}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteCheckupInstitutionPackage(packageId: string) {
|
||||
return request.del({ url: `/web/admin/checkup-institution-packages/${packageId}` })
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 耗材管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchConsumableList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({ url: '/web/admin/consumables', params })
|
||||
}
|
||||
|
||||
export function fetchAllConsumables() {
|
||||
return request.get<any[]>({ url: '/web/admin/consumables/all' })
|
||||
}
|
||||
|
||||
export function fetchCreateConsumable(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/consumables', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateConsumable(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/consumables/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteConsumable(id: string) {
|
||||
return request.del({ url: `/web/admin/consumables/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 试剂管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchReagentList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({ url: '/web/admin/reagents', params })
|
||||
}
|
||||
|
||||
export function fetchAllReagents() {
|
||||
return request.get<any[]>({ url: '/web/admin/reagents/all' })
|
||||
}
|
||||
|
||||
export function fetchCreateReagent(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/reagents', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateReagent(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/reagents/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteReagent(id: string) {
|
||||
return request.del({ url: `/web/admin/reagents/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SOP文档
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchSopList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({ url: '/web/admin/sop-documents', params })
|
||||
}
|
||||
|
||||
export function fetchAllSopDocuments() {
|
||||
return request.get<any[]>({ url: '/web/admin/sop-documents/all' })
|
||||
}
|
||||
|
||||
export function fetchCreateSop(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/sop-documents', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateSop(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/sop-documents/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteSop(id: string) {
|
||||
return request.del({ url: `/web/admin/sop-documents/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工艺工序分类
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchProcessCategoryList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({ url: '/web/admin/process-categories', params })
|
||||
}
|
||||
|
||||
export function fetchAllProcessCategories() {
|
||||
return request.get<any[]>({ url: '/web/admin/process-categories/all' })
|
||||
}
|
||||
|
||||
export function fetchCreateProcessCategory(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/process-categories', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateProcessCategory(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/process-categories/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteProcessCategory(id: string) {
|
||||
return request.del({ url: `/web/admin/process-categories/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工艺产出物类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchOutputTypeList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({ url: '/web/admin/output-types', params })
|
||||
}
|
||||
|
||||
export function fetchAllOutputTypes() {
|
||||
return request.get<any[]>({ url: '/web/admin/output-types/all' })
|
||||
}
|
||||
|
||||
export function fetchCreateOutputType(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/output-types', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateOutputType(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/output-types/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteOutputType(id: string) {
|
||||
return request.del({ url: `/web/admin/output-types/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 耗材包管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchConsumablePackList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({ url: '/web/admin/consumable-packs', params })
|
||||
}
|
||||
|
||||
export function fetchAllConsumablePacks() {
|
||||
return request.get<any[]>({ url: '/web/admin/consumable-packs/all' })
|
||||
}
|
||||
|
||||
export function fetchCreateConsumablePack(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/consumable-packs', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateConsumablePack(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/consumable-packs/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteConsumablePack(id: string) {
|
||||
return request.del({ url: `/web/admin/consumable-packs/${id}` })
|
||||
}
|
||||
|
||||
export function fetchConsumablePackDetail(id: string) {
|
||||
return request.get<any>({ url: `/web/admin/consumable-packs/${id}` })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工艺库管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fetchProcessList(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request.get<{
|
||||
list: any[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}>({ url: '/web/admin/processes', params })
|
||||
}
|
||||
|
||||
export function fetchAllProcesses() {
|
||||
return request.get<any[]>({ url: '/web/admin/processes/all' })
|
||||
}
|
||||
|
||||
export function fetchCreateProcess(data: Record<string, any>) {
|
||||
return request.post({ url: '/web/admin/processes', params: data })
|
||||
}
|
||||
|
||||
export function fetchUpdateProcess(id: string, data: Record<string, any>) {
|
||||
return request.put({ url: `/web/admin/processes/${id}`, params: data })
|
||||
}
|
||||
|
||||
export function fetchDeleteProcess(id: string) {
|
||||
return request.del({ url: `/web/admin/processes/${id}` })
|
||||
}
|
||||
|
||||
export function fetchProcessDetail(id: string) {
|
||||
return request.get<any>({ url: `/web/admin/processes/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type ResearchDonationRow = Record<string, unknown>
|
||||
|
||||
// 列表
|
||||
export function fetchResearchDonationList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
donationStatus?: string
|
||||
}) {
|
||||
return request.get<PageList<ResearchDonationRow>>({
|
||||
url: '/web/admin/research-donations',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 详情
|
||||
export function fetchResearchDonationDetail(id: string) {
|
||||
return request.get<{
|
||||
donation: Record<string, unknown>
|
||||
party: Record<string, unknown> | null
|
||||
collection: Record<string, unknown> | null
|
||||
samples: Array<Record<string, unknown>> | null
|
||||
attachments?: Array<Record<string, unknown>>
|
||||
}>({
|
||||
url: `/web/admin/research-donations/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 创建
|
||||
export function fetchCreateResearchDonation(data: Record<string, unknown>) {
|
||||
return request.post({
|
||||
url: '/web/admin/research-donations',
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 更新
|
||||
export function fetchUpdateResearchDonation(id: string, data: Record<string, unknown>) {
|
||||
return request.put({
|
||||
url: `/web/admin/research-donations/${id}`,
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
export function fetchDeleteResearchDonation(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/research-donations/${id}`,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
export function fetchBatchDeleteResearchDonation(ids: string[]) {
|
||||
return request.post({
|
||||
url: '/web/admin/research-donations/batch-delete',
|
||||
data: { ids },
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 审核列表
|
||||
export function fetchResearchDonationAuditList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
auditStatus?: string
|
||||
}) {
|
||||
return request.get<PageList<Record<string, unknown>>>({
|
||||
url: '/web/admin/research-donations/audits',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 提交审核
|
||||
export function fetchSubmitDonationAudit(
|
||||
donationId: string,
|
||||
data: { auditStatus: string; auditRemark?: string },
|
||||
options?: { showSuccessMessage?: boolean }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/research-donations/${donationId}/audit`,
|
||||
data,
|
||||
showSuccessMessage: options?.showSuccessMessage ?? true
|
||||
})
|
||||
}
|
||||
|
||||
// 状态变更
|
||||
export function fetchDonationStatusChange(
|
||||
donationId: string,
|
||||
data: { donationStatus: string; desc?: string; time?: string }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/research-donations/${donationId}/status-change`,
|
||||
data,
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 从细胞存储转化
|
||||
export function fetchConvertFromStorage(protocolId: string) {
|
||||
return request.post({
|
||||
url: '/web/admin/research-donations/convert-from-storage',
|
||||
data: { protocolId },
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
|
||||
// 转为细胞存储
|
||||
export function fetchConvertToStorage(
|
||||
donationId: string,
|
||||
data?: { desc?: string }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/research-donations/${donationId}/convert-to-storage`,
|
||||
data: data || {},
|
||||
showSuccessMessage: true
|
||||
})
|
||||
}
|
||||
@@ -589,7 +589,6 @@ body {
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
border: 1px solid var(--mint-border);
|
||||
border-radius: var(--mint-radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
|
||||
@@ -250,6 +250,34 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000001',
|
||||
name: 'ResearchDonation',
|
||||
path: '/research-donation',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '科研捐赠管理',
|
||||
icon: 'ri:hand-heart-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '60000000000000000000000000000002',
|
||||
name: 'ResearchDonationList',
|
||||
path: 'list',
|
||||
component: '/research-donation/list/index',
|
||||
meta: { title: '捐赠协议单', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000003',
|
||||
name: 'ResearchDonationAudit',
|
||||
path: 'audit',
|
||||
component: '/research-donation/audit/index',
|
||||
meta: { title: '捐赠协议审核', keepAlive: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000001',
|
||||
name: 'QualityInspection',
|
||||
@@ -374,5 +402,124 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
meta: { title: '请假申请', keepAlive: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000001',
|
||||
name: 'ProductionManagement',
|
||||
path: '/production',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '生产管理',
|
||||
icon: 'grid',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '90000000000000000000000000000010',
|
||||
name: 'ProductionConsumable',
|
||||
path: 'consumable',
|
||||
component: '/production/consumable/index',
|
||||
meta: { title: '耗材管理', keepAlive: true, menuCode: 'production:consumable:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000020',
|
||||
name: 'ProductionReagent',
|
||||
path: 'reagent',
|
||||
component: '/production/reagent/index',
|
||||
meta: { title: '试剂管理', keepAlive: true, menuCode: 'production:reagent:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000030',
|
||||
name: 'ProductionSop',
|
||||
path: 'sop',
|
||||
component: '/production/sop/index',
|
||||
meta: { title: 'SOP文档', keepAlive: true, menuCode: 'production:sop:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000040',
|
||||
name: 'ProductionProcessCategory',
|
||||
path: 'process-category',
|
||||
component: '/production/process-category/index',
|
||||
meta: { title: '工艺工序分类', keepAlive: true, menuCode: 'production:process-category:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000050',
|
||||
name: 'ProductionOutputType',
|
||||
path: 'output-type',
|
||||
component: '/production/output-type/index',
|
||||
meta: { title: '工艺产出物类型', keepAlive: true, menuCode: 'production:output-type:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000060',
|
||||
name: 'ProductionConsumablePack',
|
||||
path: 'consumable-pack',
|
||||
component: '/production/consumable-pack/index',
|
||||
meta: { title: '耗材包管理', keepAlive: true, menuCode: 'production:consumable-pack:list' }
|
||||
},
|
||||
{
|
||||
id: '90000000000000000000000000000070',
|
||||
name: 'ProductionProcess',
|
||||
path: 'process',
|
||||
component: '/production/process/index',
|
||||
meta: { title: '工艺库管理', keepAlive: true, menuCode: 'production:process:list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000001',
|
||||
name: 'PilotService',
|
||||
path: '/pilot-service',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '中试服务管理',
|
||||
icon: 'ri:flask-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '80000000000000000000000000000002',
|
||||
name: 'CellProductType',
|
||||
path: 'cell-product-type',
|
||||
component: '/pilot-service/cell-product-type/index',
|
||||
meta: { title: '细胞产品类型管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000003',
|
||||
name: 'CellProductSpec',
|
||||
path: 'cell-product-spec',
|
||||
component: '/pilot-service/cell-product-spec/index',
|
||||
meta: { title: '细胞产品规格管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000004',
|
||||
name: 'CellProductPrice',
|
||||
path: 'cell-product-price',
|
||||
component: '/pilot-service/cell-product-price/index',
|
||||
meta: { title: '细胞产品价格体系管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000005',
|
||||
name: 'ProjectPriceSystem',
|
||||
path: 'project-price-system',
|
||||
component: '/pilot-service/project-price-system/index',
|
||||
meta: { title: '细胞产品项目价格管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000006',
|
||||
name: 'Channel',
|
||||
path: 'channel',
|
||||
component: '/pilot-service/channel/index',
|
||||
meta: { title: '渠道信息管理', keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '80000000000000000000000000000007',
|
||||
name: 'CheckupInstitution',
|
||||
path: 'checkup-institution',
|
||||
component: '/pilot-service/checkup-institution/index',
|
||||
meta: { title: '体检机构及项目价格', keepAlive: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Vendored
+26
@@ -759,6 +759,32 @@ declare namespace Api {
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
namespace CellProductType {
|
||||
interface CellProductTypeItem {
|
||||
id: string
|
||||
productTypeCode: string
|
||||
productTypeName: string
|
||||
tenantNo: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
}
|
||||
|
||||
namespace CellProductSpec {
|
||||
interface CellProductSpecItem {
|
||||
id: string
|
||||
specCode: string
|
||||
specName: string
|
||||
tenantNo: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
|
||||
@@ -64,9 +64,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { fetchUpdateCollectionProtocol } from '@/api/collection-protocol'
|
||||
import { fetchConvertFromStorage } from '@/api/research-donation'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
export type StatusChangeAction = 'activate' | 'expire' | 'void' | 'toResearch'
|
||||
@@ -165,13 +166,59 @@
|
||||
if (!valid || !props.targets.length) return
|
||||
|
||||
const cfg = config.value
|
||||
const rows = [...props.targets]
|
||||
const isBatch = rows.length > 1
|
||||
|
||||
// 转科研:确认 + 调用专用接口
|
||||
if (props.actionType === 'toResearch') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定将该协议转为科研捐赠吗?转科研后将创建新的科研捐赠记录。',
|
||||
'转科研确认',
|
||||
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await withFullscreenLoading(
|
||||
async () => {
|
||||
let ok = 0
|
||||
const failed: string[] = []
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await fetchConvertFromStorage(String(row.id))
|
||||
ok += 1
|
||||
} catch {
|
||||
failed.push(row.protocolNo || String(row.id))
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (failed.length) {
|
||||
ElMessage.warning(`${cfg.label}成功 ${ok} 条,失败 ${failed.length} 条`)
|
||||
} else {
|
||||
ElMessage.success(`批量${cfg.label}成功,共 ${ok} 条`)
|
||||
}
|
||||
}
|
||||
if (ok > 0) {
|
||||
emit('update:modelValue', false)
|
||||
emit('submitted')
|
||||
}
|
||||
},
|
||||
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
protocolStatus: cfg.status,
|
||||
[cfg.timeKey]: form.changeTime,
|
||||
[cfg.descKey]: form.changeDesc.trim() || undefined
|
||||
}
|
||||
const rows = [...props.targets]
|
||||
const isBatch = rows.length > 1
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
<!-- 细胞产品价格体系管理 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>细胞产品价格体系管理</h1>
|
||||
<p class="page-desc">维护细胞产品定价体系,关联类型与规格。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">价格列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="项目名称/编码" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按项目名称、编码模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增价格
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="projectCode" label="项目编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="cellTypeName" label="细胞类型名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="specText" label="规格" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="unitPrice" label="单价" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.unitPrice != null ? `${row.unitPrice} 元` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="price-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="项目名称" prop="projectName">
|
||||
<el-input v-model="formData.projectName" placeholder="请输入项目名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目编码" prop="projectCode">
|
||||
<el-input v-model="formData.projectCode" placeholder="请输入项目编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="细胞产品类型" prop="cellProductTypeId">
|
||||
<el-select
|
||||
v-model="formData.cellProductTypeId"
|
||||
placeholder="请选择细胞产品类型"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in typeOptions"
|
||||
:key="item.id"
|
||||
:label="item.productTypeName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="规格" prop="specId">
|
||||
<el-select
|
||||
v-model="formData.specId"
|
||||
placeholder="请选择规格"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleSpecChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in specOptions"
|
||||
:key="item.id"
|
||||
:label="item.specName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="单价(元)" prop="unitPrice">
|
||||
<el-input-number
|
||||
v-model="formData.unitPrice"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
placeholder="请输入单价"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.projectName || '价格详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, Edit, Delete, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchCellProductPriceList,
|
||||
fetchCreateCellProductPrice,
|
||||
fetchUpdateCellProductPrice,
|
||||
fetchDeleteCellProductPrice,
|
||||
fetchCellProductTypeList,
|
||||
fetchCellProductSpecList
|
||||
} from '@/api/admin'
|
||||
|
||||
defineOptions({ name: 'PilotCellProductPrice' })
|
||||
|
||||
// --- Search ---
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
|
||||
// --- Table ---
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
// --- Dialog ---
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增价格')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
// --- View ---
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
|
||||
// --- Dropdown options ---
|
||||
const typeOptions = ref<any[]>([])
|
||||
const specOptions = ref<any[]>([])
|
||||
|
||||
// --- Form data ---
|
||||
const formData = reactive({
|
||||
projectName: '',
|
||||
projectCode: '',
|
||||
cellProductTypeId: '' as string,
|
||||
cellTypeCode: '',
|
||||
cellTypeName: '',
|
||||
specId: '' as string,
|
||||
specText: '',
|
||||
unitPrice: null as number | null,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
projectCode: [{ required: true, message: '请输入项目编码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
// --- View sections ---
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '项目名称', value: row.projectName },
|
||||
{ label: '项目编码', value: row.projectCode },
|
||||
{ label: '细胞类型名称', value: row.cellTypeName },
|
||||
{ label: '规格', value: row.specText },
|
||||
{ label: '单价(元)', value: row.unitPrice != null ? row.unitPrice : '-' },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// --- Load options ---
|
||||
async function loadOptions() {
|
||||
const [typeRes, specRes] = await Promise.all([
|
||||
fetchCellProductTypeList({ page: 1, pageSize: 100 }),
|
||||
fetchCellProductSpecList({ page: 1, pageSize: 100 })
|
||||
])
|
||||
typeOptions.value = (typeRes as any)?.list || []
|
||||
specOptions.value = (specRes as any)?.list || []
|
||||
}
|
||||
|
||||
// --- Load data ---
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchCellProductPriceList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = (res as any)?.list || []
|
||||
pagination.total = (res as any)?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// --- Reset form ---
|
||||
function resetForm() {
|
||||
formData.projectName = ''
|
||||
formData.projectCode = ''
|
||||
formData.cellProductTypeId = ''
|
||||
formData.cellTypeCode = ''
|
||||
formData.cellTypeName = ''
|
||||
formData.specId = ''
|
||||
formData.specText = ''
|
||||
formData.unitPrice = null
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
// --- Select handlers ---
|
||||
function handleTypeChange(val: string) {
|
||||
const selected = typeOptions.value.find((item) => item.id === val)
|
||||
if (selected) {
|
||||
formData.cellTypeCode = selected.productTypeCode || ''
|
||||
formData.cellTypeName = selected.productTypeName || ''
|
||||
} else {
|
||||
formData.cellTypeCode = ''
|
||||
formData.cellTypeName = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpecChange(val: string) {
|
||||
const selected = specOptions.value.find((item) => item.id === val)
|
||||
if (selected) {
|
||||
formData.specText = selected.specName || ''
|
||||
} else {
|
||||
formData.specText = ''
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add ---
|
||||
async function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增价格'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
await loadOptions()
|
||||
}
|
||||
|
||||
// --- Edit ---
|
||||
async function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑价格'
|
||||
editingId.value = row.id
|
||||
formData.projectName = row.projectName || ''
|
||||
formData.projectCode = row.projectCode || ''
|
||||
formData.cellProductTypeId = row.cellProductTypeId || ''
|
||||
formData.cellTypeCode = row.cellTypeCode || ''
|
||||
formData.cellTypeName = row.cellTypeName || ''
|
||||
formData.specId = row.specId || ''
|
||||
formData.specText = row.specText || ''
|
||||
formData.unitPrice = row.unitPrice ?? null
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
await loadOptions()
|
||||
}
|
||||
|
||||
// --- Submit ---
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
projectName: formData.projectName.trim(),
|
||||
projectCode: formData.projectCode.trim(),
|
||||
cellProductTypeId: formData.cellProductTypeId || undefined,
|
||||
cellTypeCode: formData.cellTypeCode || undefined,
|
||||
cellTypeName: formData.cellTypeName || undefined,
|
||||
specId: formData.specId || undefined,
|
||||
specText: formData.specText || undefined,
|
||||
unitPrice: formData.unitPrice ?? undefined,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateCellProductPrice(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateCellProductPrice(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- View ---
|
||||
function handleView(row: any) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该价格记录吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteCellProductPrice(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// --- Batch delete ---
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条记录吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteCellProductPrice(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.price-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<!-- 细胞产品规格管理 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>细胞产品规格管理</h1>
|
||||
<p class="page-desc">维护细胞产品的规格编码与名称。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">规格列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="规格编码/名称" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编码、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增规格
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="specCode" label="规格编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="specName" label="规格名称" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="remark" label="备注" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="spec-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="规格编码" prop="specCode">
|
||||
<el-input v-model="formData.specCode" placeholder="请输入规格编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格名称" prop="specName">
|
||||
<el-input v-model="formData.specName" placeholder="请输入规格名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.specName || '规格详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, Edit, Delete, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchCellProductSpecList,
|
||||
fetchCreateCellProductSpec,
|
||||
fetchUpdateCellProductSpec,
|
||||
fetchDeleteCellProductSpec
|
||||
} from '@/api/admin'
|
||||
|
||||
defineOptions({ name: 'PilotCellProductSpec' })
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
|
||||
const selectedRows = ref<Api.CellProductSpec.CellProductSpecItem[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增规格')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<Api.CellProductSpec.CellProductSpecItem | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
specCode: '',
|
||||
specName: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
specCode: [{ required: true, message: '请输入规格编码', trigger: 'blur' }],
|
||||
specName: [{ required: true, message: '请输入规格名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: Api.CellProductSpec.CellProductSpecItem[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '规格编码', value: row.specCode },
|
||||
{ label: '规格名称', value: row.specName },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchCellProductSpecList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.specCode = ''
|
||||
formData.specName = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增规格'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: Api.CellProductSpec.CellProductSpecItem) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑规格'
|
||||
editingId.value = row.id
|
||||
formData.specCode = row.specCode || ''
|
||||
formData.specName = row.specName || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
specCode: formData.specCode.trim(),
|
||||
specName: formData.specName.trim(),
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateCellProductSpec(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateCellProductSpec(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: Api.CellProductSpec.CellProductSpecItem) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.CellProductSpec.CellProductSpecItem) {
|
||||
await ElMessageBox.confirm('确定要删除该规格吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteCellProductSpec(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个规格吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteCellProductSpec(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spec-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<!-- 细胞产品类型管理 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>细胞产品类型管理</h1>
|
||||
<p class="page-desc">维护细胞产品的类型编码与名称。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">类型列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编码、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增类型
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="productTypeCode" label="类型编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="productTypeName" label="类型名称" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="remark" label="备注" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="type-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="类型编码" prop="productTypeCode">
|
||||
<el-input v-model="formData.productTypeCode" placeholder="请输入类型编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型名称" prop="productTypeName">
|
||||
<el-input v-model="formData.productTypeName" placeholder="请输入类型名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.productTypeName || '类型详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, Edit, Delete, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchCellProductTypeList,
|
||||
fetchCreateCellProductType,
|
||||
fetchUpdateCellProductType,
|
||||
fetchDeleteCellProductType
|
||||
} from '@/api/admin'
|
||||
|
||||
defineOptions({ name: 'PilotCellProductType' })
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<Api.CellProductType.CellProductTypeItem[]>([])
|
||||
const selectedRows = ref<Api.CellProductType.CellProductTypeItem[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增类型')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<Api.CellProductType.CellProductTypeItem | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
productTypeCode: '',
|
||||
productTypeName: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
productTypeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
|
||||
productTypeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: Api.CellProductType.CellProductTypeItem[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '类型编码', value: row.productTypeCode },
|
||||
{ label: '类型名称', value: row.productTypeName },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchCellProductTypeList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.productTypeCode = ''
|
||||
formData.productTypeName = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增类型'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: Api.CellProductType.CellProductTypeItem) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑类型'
|
||||
editingId.value = row.id
|
||||
formData.productTypeCode = row.productTypeCode || ''
|
||||
formData.productTypeName = row.productTypeName || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
productTypeCode: formData.productTypeCode.trim(),
|
||||
productTypeName: formData.productTypeName.trim(),
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateCellProductType(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateCellProductType(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: Api.CellProductType.CellProductTypeItem) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.CellProductType.CellProductTypeItem) {
|
||||
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteCellProductType(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个类型吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteCellProductType(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.type-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,815 @@
|
||||
<!-- 渠道信息 — 只读查看(样式对齐采集协议单 ProtocolViewDialog) -->
|
||||
<template>
|
||||
<div class="channel-view-dialog-root">
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="1100px"
|
||||
class="dialog-form-layout channel-view-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div v-loading="loading" class="channel-view-body">
|
||||
<template v-if="channel">
|
||||
<div class="detail-tabs">
|
||||
<div
|
||||
v-for="tab in viewTabs"
|
||||
:key="tab.key"
|
||||
class="detail-tab"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="scrollToSection(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi-strip">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">渠道名称</div>
|
||||
<div class="kpi-value">{{ dash(channel.channelName) }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">渠道类型</div>
|
||||
<div class="kpi-value">
|
||||
<span class="badge" :class="channelTypeBadgeClass(channel.channelType)">
|
||||
{{ channelTypeLabel(channel.channelType) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">联系人</div>
|
||||
<div class="kpi-value">{{ dash(channel.contactName) }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">创建时间</div>
|
||||
<div class="kpi-value">{{ dash(channel.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-section-basic" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>基础信息</h3></div>
|
||||
<div class="data-grid data-grid--3">
|
||||
<div class="data-item">
|
||||
<div class="data-label">渠道名称</div>
|
||||
<div class="data-value">{{ dash(channel.channelName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">统一社会信用代码</div>
|
||||
<div class="data-value">{{ dash(channel.creditCode) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">渠道类型</div>
|
||||
<div class="data-value">
|
||||
<span class="badge" :class="channelTypeBadgeClass(channel.channelType)">
|
||||
{{ channelTypeLabel(channel.channelType) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">联系人</div>
|
||||
<div class="data-value">{{ dash(channel.contactName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">联系电话</div>
|
||||
<div class="data-value">{{ dash(channel.contactPhone) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">联系邮箱</div>
|
||||
<div class="data-value">{{ dash(channel.contactEmail) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">创建时间</div>
|
||||
<div class="data-value">{{ dash(channel.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="protocolAttachmentFiles.length" class="attachment-row">
|
||||
<div class="data-label">渠道协议附件</div>
|
||||
<ProtocolAttachmentReadonly
|
||||
:files="protocolAttachmentFiles"
|
||||
@preview="openPreview"
|
||||
@download="downloadFile"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="channel.remark" class="remark-row">
|
||||
<div class="data-item data-item--full">
|
||||
<div class="data-label">备注</div>
|
||||
<div class="data-value data-value--muted">{{ dash(channel.remark) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="view-section-invoice" class="section-card section-card--green">
|
||||
<div class="section-title"><h3>开票信息</h3></div>
|
||||
<div v-if="invoiceList.length" class="sub-cards">
|
||||
<div v-for="(row, idx) in invoiceList" :key="row.id || idx" class="sub-card">
|
||||
<div class="sub-card-header">
|
||||
<h4>{{ row.companyName || '未命名' }}</h4>
|
||||
</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div v-if="row.taxNo" class="data-item">
|
||||
<div class="data-label">税号</div>
|
||||
<div class="data-value">{{ dash(row.taxNo) }}</div>
|
||||
</div>
|
||||
<div v-if="row.phone || row.companyPhone" class="data-item">
|
||||
<div class="data-label">电话</div>
|
||||
<div class="data-value">{{ dash(row.phone || row.companyPhone) }}</div>
|
||||
</div>
|
||||
<div v-if="row.companyAddress" class="data-item data-item--full">
|
||||
<div class="data-label">单位地址</div>
|
||||
<div class="data-value data-value--muted">{{ dash(row.companyAddress) }}</div>
|
||||
</div>
|
||||
<div v-if="row.bankName" class="data-item">
|
||||
<div class="data-label">开户银行</div>
|
||||
<div class="data-value">{{ dash(row.bankName) }}</div>
|
||||
</div>
|
||||
<div v-if="row.bankAccount" class="data-item">
|
||||
<div class="data-label">银行账号</div>
|
||||
<div class="data-value">{{ dash(row.bankAccount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无开票信息" :image-size="48" />
|
||||
</div>
|
||||
|
||||
<div id="view-section-mail" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>邮寄地址</h3></div>
|
||||
<div v-if="mailList.length" class="sub-cards">
|
||||
<div v-for="(row, idx) in mailList" :key="row.id || idx" class="sub-card">
|
||||
<div class="sub-card-header">
|
||||
<h4>{{ row.mailAddress || '未命名' }}</h4>
|
||||
</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div v-if="row.mailContactName || row.mailContact" class="data-item">
|
||||
<div class="data-label">联系人</div>
|
||||
<div class="data-value">{{ dash(row.mailContactName || row.mailContact) }}</div>
|
||||
</div>
|
||||
<div v-if="row.mailContactPhone || row.mailPhone" class="data-item">
|
||||
<div class="data-label">联系电话</div>
|
||||
<div class="data-value">{{ dash(row.mailContactPhone || row.mailPhone) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无邮寄地址" :image-size="48" />
|
||||
</div>
|
||||
|
||||
<div id="view-section-agreement" class="section-card section-card--gray">
|
||||
<div class="section-title"><h3>协议信息</h3></div>
|
||||
<div v-if="agreementList.length" class="agreement-cards">
|
||||
<div
|
||||
v-for="(agr, idx) in agreementList"
|
||||
:key="agr.id || idx"
|
||||
class="agreement-card"
|
||||
>
|
||||
<div class="agreement-card__header">
|
||||
<div class="agreement-card__tags">
|
||||
<span class="badge" :class="agreementTypeBadgeClass(agr.agreementType)">
|
||||
{{ agreementTypeLabel(agr.agreementType) }}
|
||||
</span>
|
||||
<span class="badge" :class="agreementStatusBadgeClass(agr.status)">
|
||||
{{ agreementStatusLabel(agr.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-grid data-grid--4">
|
||||
<div v-if="agr.settlementType" class="data-item">
|
||||
<div class="data-label">结算方式</div>
|
||||
<div class="data-value">
|
||||
<span class="badge" :class="settlementTypeBadgeClass(agr.settlementType)">
|
||||
{{ settlementTypeLabel(agr.settlementType) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="agr.creditLimit != null" class="data-item">
|
||||
<div class="data-label">授信额度(元)</div>
|
||||
<div class="data-value data-value--price">{{ dash(agr.creditLimit) }}</div>
|
||||
</div>
|
||||
<div v-if="agr.effectiveDate" class="data-item">
|
||||
<div class="data-label">生效日期</div>
|
||||
<div class="data-value">{{ dash(agr.effectiveDate) }}</div>
|
||||
</div>
|
||||
<div v-if="agr.expiryDate" class="data-item">
|
||||
<div class="data-label">到期日期</div>
|
||||
<div class="data-value">{{ dash(agr.expiryDate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无协议信息" :image-size="48" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="previewVisible" title="文件预览" width="800px" align-center destroy-on-close>
|
||||
<div v-if="previewType === 'image'" class="preview-container">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
alt="预览"
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="preview-container">
|
||||
<video
|
||||
:src="previewUrl"
|
||||
controls
|
||||
style="max-width: 100%; max-height: 70vh; display: block; margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { Close } from '@element-plus/icons-vue'
|
||||
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
|
||||
import {
|
||||
fetchChannelInvoices,
|
||||
fetchChannelMailAddresses,
|
||||
fetchChannelAgreements
|
||||
} from '@/api/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
channelId: string | null
|
||||
listRow?: Record<string, any> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
}>()
|
||||
|
||||
const viewTabs = [
|
||||
{ key: 'basic', label: '基础信息' },
|
||||
{ key: 'invoice', label: '开票信息' },
|
||||
{ key: 'mail', label: '邮寄地址' },
|
||||
{ key: 'agreement', label: '协议信息' }
|
||||
] as const
|
||||
|
||||
const channelTypeMap: Record<string, string> = {
|
||||
yy: '医院',
|
||||
hzjg: '合作机构',
|
||||
zs: '诊所',
|
||||
zfjg: '政府机构',
|
||||
qt: '其他'
|
||||
}
|
||||
|
||||
const agreementTypeMap: Record<string, string> = {
|
||||
kyxy: '框架协议',
|
||||
zsxy: '中试协议',
|
||||
lcxy: '临床协议'
|
||||
}
|
||||
|
||||
const settlementTypeMap: Record<string, string> = {
|
||||
yj: '月结',
|
||||
jj: '季结',
|
||||
nj: '年结',
|
||||
dcjs: '单次结算'
|
||||
}
|
||||
|
||||
const agreementStatusMap: Record<string, string> = {
|
||||
dsx: '待生效',
|
||||
sxz: '生效中',
|
||||
yzf: '已作废'
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
const invoiceList = ref<any[]>([])
|
||||
const mailList = ref<any[]>([])
|
||||
const agreementList = ref<any[]>([])
|
||||
|
||||
const channel = computed(() => props.listRow || null)
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
const name = channel.value?.channelName
|
||||
return name ? `查看渠道 — ${name}` : '查看渠道'
|
||||
})
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
const protocolAttachmentFiles = computed(() => {
|
||||
const urls = String(channel.value?.protocolAttachments || '').trim()
|
||||
if (!urls) return []
|
||||
return urls
|
||||
.split(',')
|
||||
.map((u, i) => {
|
||||
const url = u.trim()
|
||||
if (!url) return null
|
||||
const name = url.includes('/') ? url.split('/').pop() || `附件${i + 1}` : url
|
||||
return { id: `att_${i}`, fileUrl: url, fileName: name, fileSize: 0 }
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, any>>
|
||||
})
|
||||
|
||||
function dash(val: unknown) {
|
||||
if (val === null || val === undefined || val === '') return '—'
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function channelTypeLabel(type: string) {
|
||||
const key = String(type || '').trim()
|
||||
if (!key) return '—'
|
||||
return channelTypeMap[key] || key
|
||||
}
|
||||
|
||||
function channelTypeBadgeClass(type: string): string {
|
||||
const m: Record<string, string> = {
|
||||
yy: 'badge--blue',
|
||||
hzjg: 'badge--green',
|
||||
zs: 'badge--amber',
|
||||
zfjg: 'badge--red',
|
||||
qt: 'badge--gray'
|
||||
}
|
||||
return m[String(type || '')] || 'badge--gray'
|
||||
}
|
||||
|
||||
function agreementTypeLabel(type: string) {
|
||||
return agreementTypeMap[String(type || '')] || dash(type)
|
||||
}
|
||||
|
||||
function agreementTypeBadgeClass(type: string): string {
|
||||
const m: Record<string, string> = {
|
||||
kyxy: 'badge--blue',
|
||||
zsxy: 'badge--green',
|
||||
lcxy: 'badge--amber'
|
||||
}
|
||||
return m[String(type || '')] || 'badge--gray'
|
||||
}
|
||||
|
||||
function settlementTypeLabel(type: string) {
|
||||
return settlementTypeMap[String(type || '')] || dash(type)
|
||||
}
|
||||
|
||||
function settlementTypeBadgeClass(type: string): string {
|
||||
const m: Record<string, string> = {
|
||||
yj: 'badge--gray',
|
||||
jj: 'badge--green',
|
||||
nj: 'badge--amber',
|
||||
dcjs: 'badge--red'
|
||||
}
|
||||
return m[String(type || '')] || 'badge--gray'
|
||||
}
|
||||
|
||||
function normalizeAgreementRow(row: Record<string, any>) {
|
||||
const status = String(row.agreementStatus ?? row.status ?? 'dsx').trim() || 'dsx'
|
||||
return {
|
||||
...row,
|
||||
settlementType: row.settleMode ?? row.settlementType ?? '',
|
||||
creditLimit: row.creditLimitAmount ?? row.creditLimit ?? null,
|
||||
expiryDate: row.expireDate ?? row.expiryDate ?? '',
|
||||
effectiveDate: row.effectiveDate ?? '',
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
function agreementStatusLabel(status: string) {
|
||||
return agreementStatusMap[String(status || '')] || dash(status)
|
||||
}
|
||||
|
||||
function agreementStatusBadgeClass(status: string): string {
|
||||
const m: Record<string, string> = {
|
||||
dsx: 'badge--amber',
|
||||
sxz: 'badge--green',
|
||||
yzf: 'badge--red'
|
||||
}
|
||||
return m[String(status || '')] || 'badge--gray'
|
||||
}
|
||||
|
||||
function fullFileUrl(suffix: string) {
|
||||
if (!suffix) return ''
|
||||
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
|
||||
return suffix
|
||||
}
|
||||
return `${minioBaseUrl}/${suffix}`
|
||||
}
|
||||
|
||||
function isVideoFile(file: { fileType?: string; fileName?: string }) {
|
||||
const t = (file.fileType || '').toLowerCase()
|
||||
if (t.startsWith('video/')) return true
|
||||
return /\.(mp4|webm|ogg|mov)$/i.test(file.fileName || '')
|
||||
}
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewType = ref<'image' | 'video'>('image')
|
||||
|
||||
function openPreview(file: Record<string, any>) {
|
||||
if (!file.fileUrl) return
|
||||
previewUrl.value = fullFileUrl(file.fileUrl)
|
||||
previewType.value = isVideoFile(file) ? 'video' : 'image'
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
function downloadFile(file: Record<string, any>) {
|
||||
const url = fullFileUrl(file.fileUrl)
|
||||
if (!url) return
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = file.fileName || ''
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
async function loadSubTables() {
|
||||
if (!props.channelId) {
|
||||
invoiceList.value = []
|
||||
mailList.value = []
|
||||
agreementList.value = []
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const [invRes, mailRes, agrRes] = await Promise.all([
|
||||
fetchChannelInvoices(props.channelId),
|
||||
fetchChannelMailAddresses(props.channelId),
|
||||
fetchChannelAgreements(props.channelId)
|
||||
])
|
||||
const invRows = Array.isArray(invRes) ? invRes : (invRes as any)?.list || []
|
||||
const mailRows = Array.isArray(mailRes) ? mailRes : (mailRes as any)?.list || []
|
||||
invoiceList.value = invRows.map((row: any) => ({
|
||||
...row,
|
||||
phone: row.companyPhone ?? row.phone ?? '',
|
||||
companyPhone: row.companyPhone ?? row.phone ?? ''
|
||||
}))
|
||||
mailList.value = mailRows.map((row: any) => ({
|
||||
...row,
|
||||
mailContactName: row.mailContact ?? row.mailContactName ?? '',
|
||||
mailContactPhone: row.mailPhone ?? row.mailContactPhone ?? ''
|
||||
}))
|
||||
const agrRows = Array.isArray(agrRes) ? agrRes : (agrRes as any)?.list || []
|
||||
agreementList.value = agrRows.map((row: Record<string, any>) => normalizeAgreementRow(row))
|
||||
} catch {
|
||||
invoiceList.value = []
|
||||
mailList.value = []
|
||||
agreementList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToSection(key: string) {
|
||||
activeTab.value = key
|
||||
nextTick(() => {
|
||||
document
|
||||
.getElementById(`view-section-${key}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
})
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
invoiceList.value = []
|
||||
mailList.value = []
|
||||
agreementList.value = []
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.channelId] as const,
|
||||
([visible, id]) => {
|
||||
if (visible && id) loadSubTables()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.channel-view-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #f0fdf4;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
width: fit-content;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 1px 0 #f0fdf4;
|
||||
}
|
||||
|
||||
.detail-tab {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #059669;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: #059669;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
border-left: 3px solid #10b981;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 12px;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&--white {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
background: #10b981;
|
||||
border-radius: 2px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
display: grid;
|
||||
gap: 14px 20px;
|
||||
|
||||
&--2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&--3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&--4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.data-item {
|
||||
min-width: 0;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
|
||||
&--muted {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&--price {
|
||||
color: #059669;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
|
||||
&--green {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--blue {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remark-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.sub-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sub-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-card-header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.agreement-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a7f3d0;
|
||||
box-shadow: 0 2px 12px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.agreement-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.agreement-card .data-grid {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.data-grid--3,
|
||||
.data-grid--4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sub-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,461 @@
|
||||
<!-- 体检机构 — 项目套餐(机构内) -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="package-panel">
|
||||
<div class="list-toolbar item-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增套餐
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-body list-wrap item-table-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
max-height="420"
|
||||
@selection-change="(rows) => (selectedRows = rows)"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
|
||||
<el-table-column prop="packageName" label="套餐名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="packageCode" label="套餐编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="itemCount" label="项目数" width="80" align="center" header-align="center" />
|
||||
<el-table-column label="合计费用(元)" width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">{{
|
||||
row.totalFee != null ? `¥${row.totalFee}` : '—'
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
|
||||
{{ row.status == '1' ? '正常' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="remark"
|
||||
label="备注"
|
||||
min-width="120"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="980px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="package-dialog-form"
|
||||
scroll-to-error
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="套餐名称" prop="packageName">
|
||||
<el-input v-model="formData.packageName" placeholder="请输入套餐名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="套餐编码" prop="packageCode">
|
||||
<el-input v-model="formData.packageCode" placeholder="机构内唯一" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio value="1">正常</el-radio>
|
||||
<el-radio value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<h3 class="detail-section-title">套餐项目明细</h3>
|
||||
<el-button class="btn-main" type="primary" size="small" @click="openProjectSelector">
|
||||
<el-icon><Plus /></el-icon>添加项目
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="detailItems" size="small" class="detail-table" max-height="280">
|
||||
<el-table-column type="index" label="序号" width="55" align="center" />
|
||||
<el-table-column label="项目名称" min-width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.projectName }}
|
||||
<span v-if="row.sourceProjectDeleted" class="form-label-tip">源项目已删除</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="项目费用(元)" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.projectFee"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
|
||||
{{ row.status == '1' ? '正常' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="140" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" placeholder="可选" clearable />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button class="btn-op-item" @click="detailItems.splice($index, 1)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="package-total"
|
||||
>套餐合计:<strong>¥{{ packageTotalFee.toFixed(2) }}</strong></div
|
||||
>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="projectSelectorVisible"
|
||||
title="添加体检项目"
|
||||
width="860px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-input
|
||||
v-model="projectSelectorKeyword"
|
||||
placeholder="搜索项目名称"
|
||||
clearable
|
||||
style="width: 260px; margin-bottom: 12px"
|
||||
/>
|
||||
<el-table
|
||||
:data="filteredProjectList"
|
||||
max-height="360"
|
||||
@selection-change="(rows) => (projectSelectorSelected = rows)"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="140" align="center" />
|
||||
<el-table-column label="项目费用(元)" width="110" align="center">
|
||||
<template #default="{ row }">{{
|
||||
row.projectFee != null ? `¥${row.projectFee}` : '—'
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
|
||||
{{ row.status == '1' ? '正常' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="remark"
|
||||
label="备注"
|
||||
min-width="120"
|
||||
align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="projectSelectorVisible = false">取消</el-button>
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:disabled="projectSelectorSelected.length === 0"
|
||||
@click="confirmProjectSelector"
|
||||
>
|
||||
确认添加({{ projectSelectorSelected.length }})
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchCheckupInstitutionPackages,
|
||||
fetchCheckupInstitutionPackageDetail,
|
||||
fetchCreateCheckupInstitutionPackage,
|
||||
fetchUpdateCheckupInstitutionPackage,
|
||||
fetchDeleteCheckupInstitutionPackage,
|
||||
fetchCheckupInstitutionProjects
|
||||
} from '@/api/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
institutionId: string
|
||||
institutionName: string
|
||||
}>()
|
||||
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增套餐')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
packageName: '',
|
||||
packageCode: '',
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
packageName: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }],
|
||||
packageCode: [{ required: true, message: '请输入套餐编码', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const detailItems = ref<any[]>([])
|
||||
|
||||
const packageTotalFee = computed(() =>
|
||||
detailItems.value.reduce((sum, row) => sum + (Number(row.projectFee) || 0), 0)
|
||||
)
|
||||
|
||||
const projectSelectorVisible = ref(false)
|
||||
const projectSelectorKeyword = ref('')
|
||||
const projectSelectorList = ref<any[]>([])
|
||||
const projectSelectorSelected = ref<any[]>([])
|
||||
|
||||
const filteredProjectList = computed(() => {
|
||||
const kw = projectSelectorKeyword.value.trim().toLowerCase()
|
||||
const used = new Set(detailItems.value.map((r) => r.checkupInstitutionProjectId))
|
||||
return projectSelectorList.value.filter((row) => {
|
||||
if (row.status != '1') return false
|
||||
if (used.has(row.id)) return false
|
||||
if (!kw) return true
|
||||
return (row.projectName || '').toLowerCase().includes(kw)
|
||||
})
|
||||
})
|
||||
|
||||
function parseList(res: unknown) {
|
||||
return Array.isArray(res) ? res : (res as any)?.list || []
|
||||
}
|
||||
|
||||
async function loadPackages() {
|
||||
if (!props.institutionId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchCheckupInstitutionPackages(props.institutionId)
|
||||
tableData.value = parseList(res)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.packageName = ''
|
||||
formData.packageCode = ''
|
||||
formData.status = '1'
|
||||
formData.remark = ''
|
||||
detailItems.value = []
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增套餐'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑套餐'
|
||||
editingId.value = row.id
|
||||
dialogVisible.value = true
|
||||
try {
|
||||
const detail = await fetchCheckupInstitutionPackageDetail(row.id)
|
||||
const d = (detail as any)?.data ?? detail
|
||||
formData.packageName = d.packageName || ''
|
||||
formData.packageCode = d.packageCode || ''
|
||||
formData.status = d.status != null ? String(d.status) : '1'
|
||||
formData.remark = d.remark || ''
|
||||
detailItems.value = (d.items || []).map((it: any) => ({
|
||||
checkupInstitutionProjectId: it.checkupInstitutionProjectId,
|
||||
projectName: it.projectName,
|
||||
projectFee: it.projectFee ?? 0,
|
||||
status: it.status != null ? String(it.status) : '1',
|
||||
remark: it.remark || '',
|
||||
sourceProjectDeleted: !!it.sourceProjectDeleted
|
||||
}))
|
||||
} catch {
|
||||
detailItems.value = []
|
||||
}
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate?.().catch(() => false)
|
||||
if (!valid) return
|
||||
if (detailItems.value.length === 0) {
|
||||
ElMessage.warning('请至少添加一个体检项目')
|
||||
return
|
||||
}
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
packageName: formData.packageName.trim(),
|
||||
packageCode: formData.packageCode.trim(),
|
||||
status: formData.status,
|
||||
remark: formData.remark?.trim() || undefined,
|
||||
items: detailItems.value.map((row, idx) => ({
|
||||
checkupInstitutionProjectId: row.checkupInstitutionProjectId,
|
||||
projectName: row.projectName,
|
||||
projectFee: row.projectFee,
|
||||
remark: row.remark?.trim() || undefined,
|
||||
orderNum: idx + 1
|
||||
}))
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateCheckupInstitutionPackage(editingId.value, payload)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await fetchCreateCheckupInstitutionPackage(props.institutionId, payload)
|
||||
ElMessage.success('已新增')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadPackages()
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除套餐「${row.packageName}」?`, '提示', { type: 'warning' })
|
||||
await fetchDeleteCheckupInstitutionPackage(row.id)
|
||||
ElMessage.success('已删除')
|
||||
await loadPackages()
|
||||
}
|
||||
|
||||
async function openProjectSelector() {
|
||||
projectSelectorKeyword.value = ''
|
||||
projectSelectorSelected.value = []
|
||||
projectSelectorVisible.value = true
|
||||
try {
|
||||
const res = await fetchCheckupInstitutionProjects(props.institutionId)
|
||||
projectSelectorList.value = parseList(res)
|
||||
} catch {
|
||||
projectSelectorList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function confirmProjectSelector() {
|
||||
for (const row of projectSelectorSelected.value) {
|
||||
detailItems.value.push({
|
||||
checkupInstitutionProjectId: row.id,
|
||||
projectName: row.projectName || '',
|
||||
projectFee: row.projectFee ?? 0,
|
||||
status: row.status != null ? String(row.status) : '1',
|
||||
remark: row.remark || '',
|
||||
sourceProjectDeleted: false
|
||||
})
|
||||
}
|
||||
projectSelectorVisible.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.institutionId,
|
||||
(id) => {
|
||||
if (id) loadPackages()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.package-panel {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.package-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 16px;
|
||||
|
||||
.detail-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
border-left: 3px solid #10b981;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.package-total {
|
||||
margin-top: 12px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
color: var(--mint-text-muted);
|
||||
|
||||
strong {
|
||||
color: #059669;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,761 @@
|
||||
<!-- 体检机构 — 项目价格配置 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>体检机构及项目价格</h1>
|
||||
<p class="page-desc">维护体检机构基础信息、开票信息及项目价格管理。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">机构列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="机构名称/联系人" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.tag" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="正常" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增机构
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
|
||||
/>
|
||||
<el-table-column prop="institutionName" label="机构名称" min-width="160" align="center" header-align="center" />
|
||||
<el-table-column prop="institutionAddress" label="机构地址" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column prop="contactName" label="联系人" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="tag" label="状态" width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
|
||||
{{ row.status == '1' ? '正常' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="400" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button class="btn-op-item primary" @click="openItemDialog(row)"><el-icon><Setting /></el-icon>项目价格配置</el-button>
|
||||
<el-button class="btn-op-item" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
|
||||
@current-change="(v) => ((pagination.page = v), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑机构弹窗 -->
|
||||
<el-dialog
|
||||
v-model="companyDialogVisible"
|
||||
:title="companyDialogTitle"
|
||||
width="720px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="companyFormRef" :model="companyForm" :rules="companyFormRules" label-position="top" class="biz-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="机构名称" prop="institutionName">
|
||||
<el-input v-model="companyForm.institutionName" placeholder="请输入机构名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="机构地址">
|
||||
<el-input v-model="companyForm.institutionAddress" placeholder="请输入机构地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="companyForm.contactName" placeholder="请输入联系人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="companyForm.contactPhone" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="tag">
|
||||
<el-radio-group v-model="companyForm.tag">
|
||||
<el-radio :value="1">正常</el-radio>
|
||||
<el-radio :value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="companyForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="section-title">开票信息</div>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="发票抬头">
|
||||
<el-input v-model="companyForm.invoiceTitle" placeholder="与营业执照一致" />
|
||||
</el-form-item>
|
||||
<el-form-item label="纳税人识别号">
|
||||
<el-input v-model="companyForm.taxNo" placeholder="统一社会信用代码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户银行">
|
||||
<el-input v-model="companyForm.bankName" placeholder="收款银行全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="companyForm.bankAccount" placeholder="收款账号" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="companyDialogVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="companySubmitLoading" @click="submitCompany">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 项目价格配置弹窗 -->
|
||||
<el-dialog
|
||||
v-model="itemDialogVisible"
|
||||
:title="itemDialogTitle"
|
||||
width="1120px"
|
||||
class="dialog-form-layout checkup-item-config-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@closed="onItemDialogClosed"
|
||||
>
|
||||
<div class="item-dialog-head">
|
||||
<span class="muted">当前机构:{{ currentInstitutionName }}</span>
|
||||
</div>
|
||||
<el-tabs v-model="itemConfigTab" class="item-config-tabs">
|
||||
<el-tab-pane label="项目列表" name="projects">
|
||||
<div class="list-toolbar item-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ itemSelected.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAddItem">
|
||||
<el-icon><Plus /></el-icon>新增项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-body list-wrap item-table-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="itemTable"
|
||||
v-loading="itemLoading"
|
||||
stripe
|
||||
row-key="id"
|
||||
max-height="460"
|
||||
@selection-change="onItemSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="160" align="center" header-align="center" />
|
||||
<el-table-column prop="projectFee" label="项目费用(元)" width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ row.projectFee != null ? `¥${row.projectFee}` : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status == '1' ? 'success' : 'muted']">
|
||||
{{ row.status == '1' ? '正常' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="140" align="center" header-align="center" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEditItem(row)"><el-icon><Edit /></el-icon>编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDeleteItem(row)"><el-icon><Delete /></el-icon>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="itemPagination.page"
|
||||
:page-size="itemPagination.pageSize"
|
||||
:total="itemPagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(v) => ((itemPagination.page = 1), (itemPagination.pageSize = v), loadItems())"
|
||||
@current-change="(v) => ((itemPagination.page = v), loadItems())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="套餐管理" name="packages">
|
||||
<CheckupPackagePanel
|
||||
v-if="itemConfigTab === 'packages' && currentInstitutionId"
|
||||
:institution-id="currentInstitutionId"
|
||||
:institution-name="currentInstitutionName"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 新增/编辑项目嵌套弹窗 -->
|
||||
<el-dialog
|
||||
v-model="itemFormVisible"
|
||||
:title="itemFormTitle"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="itemFormRef" :model="itemForm" :rules="itemFormRules" label-position="top" class="biz-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="项目名称" prop="projectName">
|
||||
<el-input v-model="itemForm.projectName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目费用(元)" prop="projectFee">
|
||||
<el-input-number v-model="itemForm.projectFee" :min="0" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="itemForm.status">
|
||||
<el-radio value="1">正常</el-radio>
|
||||
<el-radio value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="itemForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="itemFormVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="itemSubmitLoading" @click="submitItem">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="itemDialogVisible = false">
|
||||
<el-icon><Close /></el-icon>关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 查看弹窗 -->
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
title="查看机构"
|
||||
:icon="OfficeBuilding"
|
||||
:meta="viewInstitutionName"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Close,
|
||||
Check,
|
||||
Search,
|
||||
RefreshLeft,
|
||||
View,
|
||||
Setting,
|
||||
Edit,
|
||||
Delete,
|
||||
OfficeBuilding
|
||||
} from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import CheckupPackagePanel from './CheckupPackagePanel.vue'
|
||||
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
import {
|
||||
fetchCheckupInstitutionList,
|
||||
fetchCreateCheckupInstitution,
|
||||
fetchUpdateCheckupInstitution,
|
||||
fetchDeleteCheckupInstitution,
|
||||
fetchCheckupInstitutionInvoices,
|
||||
fetchCreateCheckupInstitutionInvoice,
|
||||
fetchUpdateCheckupInstitutionInvoice,
|
||||
fetchDeleteCheckupInstitutionInvoice,
|
||||
fetchCheckupInstitutionProjects,
|
||||
fetchCreateCheckupInstitutionProject,
|
||||
fetchUpdateCheckupInstitutionProject,
|
||||
fetchDeleteCheckupInstitutionProject
|
||||
} from '@/api/admin'
|
||||
|
||||
defineOptions({ name: 'PilotCheckupInstitution' })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 搜索 & 主表
|
||||
// ---------------------------------------------------------------------------
|
||||
const searchForm = reactive({ keyword: '', tag: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 机构弹窗
|
||||
// ---------------------------------------------------------------------------
|
||||
const companyDialogVisible = ref(false)
|
||||
const companyDialogTitle = ref('新增机构')
|
||||
const companyIsEdit = ref(false)
|
||||
const companySubmitLoading = ref(false)
|
||||
const companyFormRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const companyForm = reactive({
|
||||
institutionName: '',
|
||||
institutionAddress: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
invoiceTitle: '',
|
||||
taxNo: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
tag: 1 as number,
|
||||
remark: ''
|
||||
})
|
||||
const companyFormRules = {
|
||||
institutionName: [{ required: true, message: '请输入机构名称', trigger: 'blur' }],
|
||||
tag: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 项目价格配置弹窗
|
||||
// ---------------------------------------------------------------------------
|
||||
const itemDialogVisible = ref(false)
|
||||
const itemDialogTitle = ref('项目价格配置')
|
||||
const itemConfigTab = ref<'projects' | 'packages'>('projects')
|
||||
const currentInstitutionId = ref('')
|
||||
const currentInstitutionName = ref('')
|
||||
const itemTable = ref<any[]>([])
|
||||
const itemSelected = ref<any[]>([])
|
||||
const itemLoading = ref(false)
|
||||
const itemPagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const itemFormVisible = ref(false)
|
||||
const itemFormTitle = ref('新增项目')
|
||||
const itemIsEdit = ref(false)
|
||||
const itemSubmitLoading = ref(false)
|
||||
const itemFormRef = ref()
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const itemForm = reactive({
|
||||
projectName: '',
|
||||
projectFee: 0 as number | null,
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
const itemFormRules = {
|
||||
projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查看弹窗
|
||||
// ---------------------------------------------------------------------------
|
||||
const viewVisible = ref(false)
|
||||
const viewInstitutionName = ref('')
|
||||
const viewSections = ref<ViewSection[]>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 主表数据加载
|
||||
// ---------------------------------------------------------------------------
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchCheckupInstitutionList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
tag: searchForm.tag ? Number(searchForm.tag) : undefined, // 后端 list 接口暂无 tag/status 过滤参数
|
||||
})
|
||||
tableData.value = (res as any)?.list || []
|
||||
pagination.total = (res as any)?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.tag = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: any[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 机构弹窗操作
|
||||
// ---------------------------------------------------------------------------
|
||||
function resetCompanyForm() {
|
||||
companyForm.institutionName = ''
|
||||
companyForm.institutionAddress = ''
|
||||
companyForm.contactName = ''
|
||||
companyForm.contactPhone = ''
|
||||
companyForm.invoiceTitle = ''
|
||||
companyForm.taxNo = ''
|
||||
companyForm.bankName = ''
|
||||
companyForm.bankAccount = ''
|
||||
companyForm.tag = 1
|
||||
companyForm.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
companyIsEdit.value = false
|
||||
companyDialogTitle.value = '新增机构'
|
||||
resetCompanyForm()
|
||||
companyDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEdit(row: any) {
|
||||
companyIsEdit.value = true
|
||||
companyDialogTitle.value = '编辑机构'
|
||||
editingId.value = row.id
|
||||
companyForm.institutionName = row.institutionName || ''
|
||||
companyForm.institutionAddress = row.institutionAddress || ''
|
||||
companyForm.contactName = row.contactName || ''
|
||||
companyForm.contactPhone = row.contactPhone || ''
|
||||
companyForm.tag = row.status != null ? Number(row.status) : 1
|
||||
companyForm.remark = row.remark || ''
|
||||
// 加载开票信息
|
||||
try {
|
||||
const invRes = await fetchCheckupInstitutionInvoices(row.id)
|
||||
const invoices = (invRes as any)?.list || (invRes as any) || []
|
||||
if (invoices.length > 0) {
|
||||
const inv = invoices[0]
|
||||
companyForm.invoiceTitle = inv.invoiceTitle || inv.companyName || ''
|
||||
companyForm.taxNo = inv.taxNo || ''
|
||||
companyForm.bankName = inv.bankName || ''
|
||||
companyForm.bankAccount = inv.bankAccount || ''
|
||||
} else {
|
||||
companyForm.invoiceTitle = ''
|
||||
companyForm.taxNo = ''
|
||||
companyForm.bankName = ''
|
||||
companyForm.bankAccount = ''
|
||||
}
|
||||
} catch {
|
||||
companyForm.invoiceTitle = ''
|
||||
companyForm.taxNo = ''
|
||||
companyForm.bankName = ''
|
||||
companyForm.bankAccount = ''
|
||||
}
|
||||
companyDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitCompany() {
|
||||
const valid = await companyFormRef.value?.validate?.().catch(() => false)
|
||||
if (!valid) return
|
||||
companySubmitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
institutionName: companyForm.institutionName.trim(),
|
||||
institutionAddress: companyForm.institutionAddress?.trim() || undefined,
|
||||
contactName: companyForm.contactName?.trim() || undefined,
|
||||
contactPhone: companyForm.contactPhone?.trim() || undefined,
|
||||
status: String(companyForm.tag),
|
||||
remark: companyForm.remark?.trim() || undefined
|
||||
}
|
||||
if (companyIsEdit.value && editingId.value) {
|
||||
await fetchUpdateCheckupInstitution(editingId.value, payload)
|
||||
// 同步开票信息
|
||||
const invRes = await fetchCheckupInstitutionInvoices(editingId.value)
|
||||
const invoices = (invRes as any)?.list || (invRes as any) || []
|
||||
const invoicePayload = {
|
||||
companyName: companyForm.invoiceTitle?.trim() || undefined,
|
||||
invoiceTitle: companyForm.invoiceTitle?.trim() || undefined,
|
||||
taxNo: companyForm.taxNo?.trim() || undefined,
|
||||
bankName: companyForm.bankName?.trim() || undefined,
|
||||
bankAccount: companyForm.bankAccount?.trim() || undefined
|
||||
}
|
||||
if (invoices.length > 0) {
|
||||
await fetchUpdateCheckupInstitutionInvoice(invoices[0].id, invoicePayload)
|
||||
} else if (companyForm.invoiceTitle) {
|
||||
await fetchCreateCheckupInstitutionInvoice(editingId.value, invoicePayload)
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
const newInst = await fetchCreateCheckupInstitution(payload)
|
||||
// 新增时同步创建开票信息
|
||||
if ((newInst as any)?.id && companyForm.invoiceTitle?.trim()) {
|
||||
const invoicePayload = {
|
||||
companyName: companyForm.invoiceTitle?.trim() || undefined,
|
||||
invoiceTitle: companyForm.invoiceTitle?.trim() || undefined,
|
||||
taxNo: companyForm.taxNo?.trim() || undefined,
|
||||
bankName: companyForm.bankName?.trim() || undefined,
|
||||
bankAccount: companyForm.bankAccount?.trim() || undefined,
|
||||
}
|
||||
await fetchCreateCheckupInstitutionInvoice((newInst as any).id, invoicePayload)
|
||||
}
|
||||
ElMessage.success('已新增')
|
||||
}
|
||||
companyDialogVisible.value = false
|
||||
loadData()
|
||||
} finally {
|
||||
companySubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.institutionName}」?其下属项目价格将一并删除。`, '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
await fetchDeleteCheckupInstitution(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查看弹窗
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handleView(row: any) {
|
||||
viewInstitutionName.value = row.institutionName || ''
|
||||
const sections: ViewSection[] = [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '机构名称', value: row.institutionName },
|
||||
{ label: '机构地址', value: row.institutionAddress, span: true },
|
||||
{ label: '联系人', value: row.contactName },
|
||||
{ label: '联系电话', value: row.contactPhone },
|
||||
{ label: '状态', value: row.status == '1' ? '正常' : '停用' },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
// 加载开票信息
|
||||
try {
|
||||
const invRes = await fetchCheckupInstitutionInvoices(row.id)
|
||||
const invoices = (invRes as any)?.list || (invRes as any) || []
|
||||
if (invoices.length > 0) {
|
||||
const inv = invoices[0]
|
||||
sections.push({
|
||||
title: '开票信息',
|
||||
fields: [
|
||||
{ label: '发票抬头', value: inv.invoiceTitle || inv.companyName },
|
||||
{ label: '纳税人识别号', value: inv.taxNo },
|
||||
{ label: '开户银行', value: inv.bankName },
|
||||
{ label: '银行账号', value: inv.bankAccount }
|
||||
]
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
viewSections.value = sections
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 项目价格配置弹窗
|
||||
// ---------------------------------------------------------------------------
|
||||
function openItemDialog(row: any, tab: 'projects' | 'packages' = 'projects') {
|
||||
currentInstitutionId.value = row.id
|
||||
currentInstitutionName.value = row.institutionName || ''
|
||||
itemPagination.page = 1
|
||||
itemSelected.value = []
|
||||
itemConfigTab.value = tab
|
||||
itemDialogTitle.value = `项目价格配置 — ${row.institutionName}`
|
||||
itemDialogVisible.value = true
|
||||
loadItems()
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
if (!currentInstitutionId.value) return
|
||||
itemLoading.value = true
|
||||
try {
|
||||
const res = await fetchCheckupInstitutionProjects(currentInstitutionId.value)
|
||||
const list = Array.isArray(res) ? res : (res as any)?.list || []
|
||||
itemTable.value = list
|
||||
itemPagination.total = Array.isArray(res) ? list.length : ((res as any)?.total ?? list.length)
|
||||
} finally {
|
||||
itemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onItemDialogClosed() {
|
||||
currentInstitutionId.value = ''
|
||||
currentInstitutionName.value = ''
|
||||
itemTable.value = []
|
||||
}
|
||||
|
||||
function onItemSelectionChange(rows: any[]) {
|
||||
itemSelected.value = rows
|
||||
}
|
||||
|
||||
function resetItemForm() {
|
||||
itemForm.projectName = ''
|
||||
itemForm.projectFee = 0
|
||||
itemForm.status = '1'
|
||||
itemForm.remark = ''
|
||||
editingItemId.value = null
|
||||
}
|
||||
|
||||
function handleAddItem() {
|
||||
itemIsEdit.value = false
|
||||
itemFormTitle.value = '新增项目'
|
||||
resetItemForm()
|
||||
itemFormVisible.value = true
|
||||
}
|
||||
|
||||
function handleEditItem(row: any) {
|
||||
itemIsEdit.value = true
|
||||
itemFormTitle.value = '编辑项目'
|
||||
editingItemId.value = row.id
|
||||
itemForm.projectName = row.projectName || ''
|
||||
itemForm.projectFee = row.projectFee ?? 0
|
||||
itemForm.status = row.status != null ? String(row.status) : '1'
|
||||
itemForm.remark = row.remark || ''
|
||||
itemFormVisible.value = true
|
||||
}
|
||||
|
||||
async function submitItem() {
|
||||
const valid = await itemFormRef.value?.validate?.().catch(() => false)
|
||||
if (!valid) return
|
||||
itemSubmitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
projectName: itemForm.projectName.trim(),
|
||||
projectFee: itemForm.projectFee,
|
||||
status: itemForm.status,
|
||||
remark: itemForm.remark?.trim() || undefined
|
||||
}
|
||||
if (itemIsEdit.value && editingItemId.value) {
|
||||
await fetchUpdateCheckupInstitutionProject(editingItemId.value, payload)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await fetchCreateCheckupInstitutionProject(currentInstitutionId.value, payload)
|
||||
ElMessage.success('已新增')
|
||||
}
|
||||
itemFormVisible.value = false
|
||||
loadItems()
|
||||
} finally {
|
||||
itemSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.projectName}」?`, '提示', { type: 'warning' })
|
||||
await fetchDeleteCheckupInstitutionProject(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.biz-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-dialog-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.item-dialog-head .muted {
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted);
|
||||
}
|
||||
|
||||
.item-config-tabs {
|
||||
margin-top: 4px;
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
min-height: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-toolbar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.item-table-wrap {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.form-section .section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 弹窗 teleport 到 body,高度类名需非 scoped -->
|
||||
<style lang="scss">
|
||||
.el-dialog.checkup-item-config-dialog.dialog-form-layout {
|
||||
min-height: min(720px, calc(100vh - 80px));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,653 @@
|
||||
<!-- 细胞产品项目价格管理(主子表) -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>细胞产品项目价格管理</h1>
|
||||
<p class="page-desc">维护项目价格体系,关联价格明细。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">体系列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="体系名称/编码" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按体系名称、编码模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增体系
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="systemName"
|
||||
label="体系名称"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="systemCode"
|
||||
label="体系编码"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column prop="status" label="状态" min-width="80" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="String(row.status) === '1' ? 'success' : 'danger'" size="small">
|
||||
{{ String(row.status) === '1' ? '正常' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑弹窗 — 主子表 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="1100px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="system-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="体系名称" prop="systemName">
|
||||
<el-input v-model="formData.systemName" placeholder="请输入体系名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="体系编码" prop="systemCode">
|
||||
<el-input v-model="formData.systemCode" placeholder="请输入体系编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
|
||||
<el-option label="正常" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 价格明细 — 子表 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-head">
|
||||
<h3 class="detail-section-title">价格明细</h3>
|
||||
<el-button class="btn-main" type="primary" size="small" @click="handleOpenPriceSelector">
|
||||
<el-icon><Plus /></el-icon>
|
||||
从价格体系添加
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="detailItems" size="small" class="detail-table" max-height="320">
|
||||
<el-table-column type="index" label="序号" width="55" align="center" header-align="center" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="projectCode" label="项目编码" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="cellTypeName" label="细胞类型" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="specText" label="规格" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column label="单价(元)" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<el-input-number
|
||||
v-model="row.unitPrice"
|
||||
:precision="2"
|
||||
:min="0"
|
||||
:controls="false"
|
||||
size="default"
|
||||
style="width: 100%"
|
||||
placeholder="请输入单价"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button class="btn-op-item" type="danger" link size="small" @click="detailItems.splice($index, 1)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 从价格体系添加 — 子弹窗 -->
|
||||
<el-dialog
|
||||
v-model="priceSelectorVisible"
|
||||
title="从价格体系添加"
|
||||
width="860px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div style="margin-bottom: 12px">
|
||||
<el-input
|
||||
v-model="priceSelectorKeyword"
|
||||
placeholder="搜索项目名称/编码"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
/>
|
||||
</div>
|
||||
<el-table
|
||||
ref="priceSelectorTableRef"
|
||||
:data="filteredPriceSelectorList"
|
||||
max-height="400"
|
||||
@selection-change="handlePriceSelectorChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column prop="projectName" label="项目名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="projectCode" label="项目编码" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="cellTypeName" label="细胞类型" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="specText" label="规格" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column label="单价" min-width="80" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.unitPrice != null ? `${row.unitPrice} 元` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="priceSelectorVisible = false">取消</el-button>
|
||||
<el-button
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:disabled="priceSelectorSelected.length === 0"
|
||||
@click="handleConfirmPriceSelector"
|
||||
>
|
||||
确认添加({{ priceSelectorSelected.length }})
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.systemName || '体系详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, Edit, Delete, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchProjectSystemList,
|
||||
fetchCreateProjectSystem,
|
||||
fetchUpdateProjectSystem,
|
||||
fetchDeleteProjectSystem,
|
||||
fetchProjectSystemItems,
|
||||
fetchCreateProjectSystemItem,
|
||||
fetchUpdateProjectSystemItem,
|
||||
fetchDeleteProjectSystemItem,
|
||||
fetchCellProductPriceList
|
||||
} from '@/api/admin'
|
||||
|
||||
defineOptions({ name: 'PilotProjectPriceSystem' })
|
||||
|
||||
// --- Search ---
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
|
||||
// --- Table ---
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
// --- Dialog ---
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增体系')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
// --- View ---
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
|
||||
// --- Detail items ---
|
||||
const detailItems = ref<any[]>([])
|
||||
|
||||
// --- Price selector ---
|
||||
const priceSelectorVisible = ref(false)
|
||||
const priceSelectorList = ref<any[]>([])
|
||||
const priceSelectorSelected = ref<any[]>([])
|
||||
const priceSelectorTableRef = ref()
|
||||
const priceSelectorKeyword = ref('')
|
||||
|
||||
const filteredPriceSelectorList = computed(() => {
|
||||
const kw = priceSelectorKeyword.value.trim().toLowerCase()
|
||||
if (!kw) return priceSelectorList.value
|
||||
return priceSelectorList.value.filter(
|
||||
(row: any) =>
|
||||
(row.projectName || '').toLowerCase().includes(kw) ||
|
||||
(row.projectCode || '').toLowerCase().includes(kw)
|
||||
)
|
||||
})
|
||||
|
||||
// --- Form data ---
|
||||
const formData = reactive({
|
||||
systemName: '',
|
||||
systemCode: '',
|
||||
status: '1' as string,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
systemName: [{ required: true, message: '请输入体系名称', trigger: 'blur' }],
|
||||
systemCode: [{ required: true, message: '请输入体系编码', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
// --- View sections ---
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
const items = viewDetailItems.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '体系名称', value: row.systemName },
|
||||
{ label: '体系编码', value: row.systemCode },
|
||||
{
|
||||
label: '状态',
|
||||
value: String(row.status) === '1' ? '正常' : '停用'
|
||||
},
|
||||
{ label: '备注', value: row.remark, span: true },
|
||||
{ label: '创建时间', value: row.createTime }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '价格明细',
|
||||
fields: items.map((item: any, idx: number) => ({
|
||||
label: `明细 ${idx + 1}`,
|
||||
value: `${item.projectName || '-'} / ${item.cellTypeName || '-'} / ${item.specText || '-'} / ${item.unitPrice != null ? item.unitPrice + ' 元' : '-'}`,
|
||||
span: true
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const viewDetailItems = ref<any[]>([])
|
||||
|
||||
// --- Load data ---
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchProjectSystemList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = (res as any)?.list || []
|
||||
pagination.total = (res as any)?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// --- Reset form ---
|
||||
function resetForm() {
|
||||
formData.systemName = ''
|
||||
formData.systemCode = ''
|
||||
formData.status = '1'
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
detailItems.value = []
|
||||
}
|
||||
|
||||
// --- Add ---
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增体系'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
// --- Edit ---
|
||||
async function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑体系'
|
||||
editingId.value = row.id
|
||||
formData.systemName = row.systemName || ''
|
||||
formData.systemCode = row.systemCode || ''
|
||||
formData.status = row.status != null ? String(row.status) : '1'
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
// load existing detail items
|
||||
try {
|
||||
const res = await fetchProjectSystemItems(row.id)
|
||||
detailItems.value = ((res as any)?.list || (res as any) || []).map((item: any) => ({ ...item }))
|
||||
} catch {
|
||||
detailItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Submit ---
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const masterPayload = {
|
||||
systemName: formData.systemName.trim(),
|
||||
systemCode: formData.systemCode.trim(),
|
||||
status: formData.status,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
|
||||
let masterId: string
|
||||
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateProjectSystem(editingId.value, masterPayload)
|
||||
masterId = editingId.value
|
||||
} else {
|
||||
const createRes = await fetchCreateProjectSystem(masterPayload)
|
||||
masterId = (createRes as any)?.id || editingId.value!
|
||||
}
|
||||
|
||||
// Handle detail items: for simplicity, delete all existing then re-create
|
||||
if (isEdit.value) {
|
||||
const existingRes = await fetchProjectSystemItems(masterId)
|
||||
const existingItems: any[] = (existingRes as any)?.list || (existingRes as any) || []
|
||||
for (const existing of existingItems) {
|
||||
if (existing.id) {
|
||||
await fetchDeleteProjectSystemItem(existing.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create all current detail items
|
||||
for (const item of detailItems.value) {
|
||||
const itemPayload = {
|
||||
sourcePriceId: item.sourcePriceId || undefined,
|
||||
projectName: item.projectName || undefined,
|
||||
projectCode: item.projectCode || undefined,
|
||||
cellTypeCode: item.cellTypeCode || undefined,
|
||||
cellTypeName: item.cellTypeName || undefined,
|
||||
specText: item.specText || undefined,
|
||||
specId: item.specId || undefined,
|
||||
cellProductTypeId: item.cellProductTypeId || undefined,
|
||||
unitPrice: item.unitPrice ?? undefined
|
||||
}
|
||||
await fetchCreateProjectSystemItem(masterId, itemPayload)
|
||||
}
|
||||
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- View ---
|
||||
async function handleView(row: any) {
|
||||
viewRow.value = row
|
||||
try {
|
||||
const res = await fetchProjectSystemItems(row.id)
|
||||
viewDetailItems.value = (res as any)?.list || (res as any) || []
|
||||
} catch {
|
||||
viewDetailItems.value = []
|
||||
}
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该体系吗?删除后关联的价格明细也将清除。', '提示', { type: 'warning' })
|
||||
await fetchDeleteProjectSystem(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// --- Batch delete ---
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条记录吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteProjectSystem(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
// --- Price selector ---
|
||||
async function handleOpenPriceSelector() {
|
||||
priceSelectorKeyword.value = ''
|
||||
priceSelectorSelected.value = []
|
||||
priceSelectorVisible.value = true
|
||||
try {
|
||||
const res = await fetchCellProductPriceList({ page: 1, pageSize: 100 })
|
||||
priceSelectorList.value = (res as any)?.list || []
|
||||
} catch {
|
||||
priceSelectorList.value = []
|
||||
}
|
||||
// Pre-check items already in detailItems (by sourcePriceId)
|
||||
await nextTick()
|
||||
const existingIds = new Set(detailItems.value.map((item) => item.sourcePriceId))
|
||||
const tableRef = priceSelectorTableRef.value
|
||||
if (tableRef) {
|
||||
priceSelectorList.value.forEach((row: any) => {
|
||||
if (existingIds.has(row.id)) {
|
||||
tableRef.toggleRowSelection(row, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handlePriceSelectorChange(rows: any[]) {
|
||||
priceSelectorSelected.value = rows
|
||||
}
|
||||
|
||||
function handleConfirmPriceSelector() {
|
||||
// Filter out items that already exist in detailItems (by sourcePriceId)
|
||||
const existingIds = new Set(detailItems.value.map((item) => item.sourcePriceId))
|
||||
for (const row of priceSelectorSelected.value) {
|
||||
if (existingIds.has(row.id)) continue
|
||||
detailItems.value.push({
|
||||
sourcePriceId: row.id,
|
||||
projectName: row.projectName || '',
|
||||
projectCode: row.projectCode || '',
|
||||
cellTypeCode: row.cellTypeCode || '',
|
||||
cellTypeName: row.cellTypeName || '',
|
||||
specText: row.specText || '',
|
||||
specId: row.specId || '',
|
||||
cellProductTypeId: row.cellProductTypeId || '',
|
||||
unitPrice: row.unitPrice ?? null
|
||||
})
|
||||
}
|
||||
priceSelectorVisible.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-top: 20px;
|
||||
|
||||
.detail-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
border-left: 3px solid #10b981;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__inner-wrapper) {
|
||||
border: none !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__cell) {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr:last-child td.el-table__cell) {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,537 @@
|
||||
<!-- 耗材包管理 -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>耗材包管理</h1>
|
||||
<p class="page-desc">维护标准与自定义耗材包,配置包内耗材明细与用量。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">耗材包列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="编号/名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编号、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增耗材包
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="packCode"
|
||||
label="编号"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="packName"
|
||||
label="名称"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="usageScene"
|
||||
label="使用场景"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="packType"
|
||||
label="类型"
|
||||
min-width="130"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.packType === 'bzhcb' ? 'primary' : 'warning'" size="small">
|
||||
{{ row.packType === 'bzhcb' ? '标准耗材包' : '自定义耗材包' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
min-width="90"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="800px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="pack-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">基本信息</div>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="编号" prop="packCode">
|
||||
<el-input v-model="formData.packCode" placeholder="请输入编号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="packName">
|
||||
<el-input v-model="formData.packName" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用场景">
|
||||
<el-input v-model="formData.usageScene" placeholder="请输入使用场景" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="packType">
|
||||
<el-radio-group v-model="formData.packType">
|
||||
<el-radio value="bzhcb">标准耗材包</el-radio>
|
||||
<el-radio value="zdyhcb">自定义耗材包</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 耗材明细 -->
|
||||
<div class="form-section">
|
||||
<div class="section-title">
|
||||
<span>耗材明细</span>
|
||||
<el-button class="btn-main" type="primary" size="small" @click="openAddConsumableDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加耗材
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="formData.items" stripe size="small" class="inner-table">
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column label="标准用量" min-width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="80" align="center" header-align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="formData.items.splice($index, 1)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="formData.items.length === 0" description="暂无耗材,点击上方按钮添加" :image-size="48" />
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 选择耗材内弹窗 -->
|
||||
<el-dialog
|
||||
v-model="selectConsumableVisible"
|
||||
title="选择耗材"
|
||||
width="700px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-input
|
||||
v-model="consumableKeyword"
|
||||
placeholder="搜索耗材编码/名称"
|
||||
clearable
|
||||
style="margin-bottom: 12px"
|
||||
@input="filterConsumables"
|
||||
/>
|
||||
<el-table
|
||||
:data="filteredConsumableList"
|
||||
stripe
|
||||
size="small"
|
||||
height="360"
|
||||
highlight-current-row
|
||||
@current-change="handleConsumableSelect"
|
||||
>
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="selectConsumableVisible = false">取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :disabled="!selectedConsumable" @click="confirmAddConsumable">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Close, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchConsumablePackList,
|
||||
fetchConsumablePackDetail,
|
||||
fetchCreateConsumablePack,
|
||||
fetchUpdateConsumablePack,
|
||||
fetchDeleteConsumablePack,
|
||||
fetchAllConsumables
|
||||
} from '@/api/production'
|
||||
|
||||
defineOptions({ name: 'ProductionConsumablePack' })
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增耗材包')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
packCode: '',
|
||||
packName: '',
|
||||
usageScene: '',
|
||||
packType: 'bzhcb',
|
||||
status: 1,
|
||||
remark: '',
|
||||
items: [] as any[]
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
packCode: [{ required: true, message: '请输入编号', trigger: 'blur' }],
|
||||
packName: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
packType: [{ required: true, message: '请选择类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 选择耗材弹窗
|
||||
const selectConsumableVisible = ref(false)
|
||||
const allConsumableList = ref<any[]>([])
|
||||
const filteredConsumableList = ref<any[]>([])
|
||||
const consumableKeyword = ref('')
|
||||
const selectedConsumable = ref<any>(null)
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchConsumablePackList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.packCode = ''
|
||||
formData.packName = ''
|
||||
formData.usageScene = ''
|
||||
formData.packType = 'bzhcb'
|
||||
formData.status = 1
|
||||
formData.remark = ''
|
||||
formData.items = []
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增耗材包'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
async function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑耗材包'
|
||||
editingId.value = row.id
|
||||
resetForm()
|
||||
try {
|
||||
const detail = await fetchConsumablePackDetail(row.id)
|
||||
formData.packCode = detail.packCode || ''
|
||||
formData.packName = detail.packName || ''
|
||||
formData.usageScene = detail.usageScene || ''
|
||||
formData.packType = detail.packType || 'bzhcb'
|
||||
formData.status = detail.status ?? 1
|
||||
formData.remark = detail.remark || ''
|
||||
formData.items = (detail.items || []).map((item: any) => ({ ...item }))
|
||||
} catch {
|
||||
ElMessage.error('加载详情失败')
|
||||
}
|
||||
dialogVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
packCode: formData.packCode.trim(),
|
||||
packName: formData.packName.trim(),
|
||||
usageScene: formData.usageScene?.trim() || undefined,
|
||||
packType: formData.packType,
|
||||
status: formData.status,
|
||||
remark: formData.remark?.trim() || undefined,
|
||||
items: formData.items.map((item) => ({
|
||||
consumableId: item.consumableId || item.id,
|
||||
consumableCode: item.consumableCode,
|
||||
consumableName: item.consumableName,
|
||||
specModel: item.specModel,
|
||||
standardUsage: item.standardUsage,
|
||||
remark: item.remark || ''
|
||||
}))
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateConsumablePack(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateConsumablePack(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该耗材包吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteConsumablePack(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个耗材包吗?`, '提示', { type: 'warning' })
|
||||
for (const id of ids) {
|
||||
await fetchDeleteConsumablePack(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// 选择耗材弹窗
|
||||
async function openAddConsumableDialog() {
|
||||
selectedConsumable.value = null
|
||||
consumableKeyword.value = ''
|
||||
if (allConsumableList.value.length === 0) {
|
||||
try {
|
||||
allConsumableList.value = await fetchAllConsumables()
|
||||
} catch {
|
||||
allConsumableList.value = []
|
||||
}
|
||||
}
|
||||
filteredConsumableList.value = [...allConsumableList.value]
|
||||
selectConsumableVisible.value = true
|
||||
}
|
||||
|
||||
function filterConsumables() {
|
||||
const kw = consumableKeyword.value.trim().toLowerCase()
|
||||
if (!kw) {
|
||||
filteredConsumableList.value = [...allConsumableList.value]
|
||||
return
|
||||
}
|
||||
filteredConsumableList.value = allConsumableList.value.filter(
|
||||
(c) =>
|
||||
(c.consumableCode || '').toLowerCase().includes(kw) ||
|
||||
(c.consumableName || '').toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
function handleConsumableSelect(row: any) {
|
||||
selectedConsumable.value = row
|
||||
}
|
||||
|
||||
function confirmAddConsumable() {
|
||||
if (!selectedConsumable.value) return
|
||||
const c = selectedConsumable.value
|
||||
// 检查是否已添加
|
||||
const exists = formData.items.some(
|
||||
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
|
||||
)
|
||||
if (exists) {
|
||||
ElMessage.warning('该耗材已在明细中')
|
||||
return
|
||||
}
|
||||
formData.items.push({
|
||||
consumableId: c.id,
|
||||
consumableCode: c.consumableCode || '',
|
||||
consumableName: c.consumableName || '',
|
||||
specModel: c.specModel || '',
|
||||
standardUsage: '',
|
||||
remark: ''
|
||||
})
|
||||
selectConsumableVisible.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pack-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.inner-table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<!-- 耗材管理 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>耗材管理</h1>
|
||||
<p class="page-desc">维护生产耗材的编码、名称、规格及标准用量等信息。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">耗材列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="耗材编码/名称" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编码、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增耗材
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="130" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="standardUsage" label="标准用量" min-width="110" align="center" header-align="center" />
|
||||
<el-table-column label="描述" min-width="160" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ truncateText(row.description, 20) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="consumable-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="耗材编码" prop="consumableCode">
|
||||
<el-input v-model="formData.consumableCode" placeholder="请输入耗材编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="耗材名称" prop="consumableName">
|
||||
<el-input v-model="formData.consumableName" placeholder="请输入耗材名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格型号">
|
||||
<el-input v-model="formData.specModel" placeholder="请输入规格型号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标准用量">
|
||||
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" class="form-col-span-2">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.consumableName || '耗材详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchConsumableList,
|
||||
fetchCreateConsumable,
|
||||
fetchUpdateConsumable,
|
||||
fetchDeleteConsumable
|
||||
} from '@/api/production'
|
||||
|
||||
defineOptions({ name: 'ProductionConsumable' })
|
||||
|
||||
function truncateText(text: string, max: number) {
|
||||
if (!text) return ''
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增耗材')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
|
||||
const formData = reactive({
|
||||
consumableCode: '',
|
||||
consumableName: '',
|
||||
specModel: '',
|
||||
standardUsage: '',
|
||||
description: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
consumableCode: [{ required: true, message: '请输入耗材编码', trigger: 'blur' }],
|
||||
consumableName: [{ required: true, message: '请输入耗材名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '耗材编码', value: row.consumableCode },
|
||||
{ label: '耗材名称', value: row.consumableName },
|
||||
{ label: '规格型号', value: row.specModel },
|
||||
{ label: '标准用量', value: row.standardUsage },
|
||||
{ label: '描述', value: row.description, span: true },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchConsumableList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.consumableCode = ''
|
||||
formData.consumableName = ''
|
||||
formData.specModel = ''
|
||||
formData.standardUsage = ''
|
||||
formData.description = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增耗材'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑耗材'
|
||||
editingId.value = row.id
|
||||
formData.consumableCode = row.consumableCode || ''
|
||||
formData.consumableName = row.consumableName || ''
|
||||
formData.specModel = row.specModel || ''
|
||||
formData.standardUsage = row.standardUsage ?? ''
|
||||
formData.description = row.description || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
consumableCode: formData.consumableCode.trim(),
|
||||
consumableName: formData.consumableName.trim(),
|
||||
specModel: formData.specModel?.trim() || undefined,
|
||||
standardUsage: formData.standardUsage ?? undefined,
|
||||
description: formData.description?.trim() || undefined,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateConsumable(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateConsumable(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该耗材吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteConsumable(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个耗材吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteConsumable(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.consumable-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<!-- 工艺产出物类型 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>工艺产出物类型</h1>
|
||||
<p class="page-desc">维护工艺产出物的类型编码与名称。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">类型列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编码、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增类型
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="typeCode" label="类型编码" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="typeName" label="类型名称" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="output-type-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="类型编码" prop="typeCode">
|
||||
<el-input v-model="formData.typeCode" placeholder="请输入类型编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型名称" prop="typeName">
|
||||
<el-input v-model="formData.typeName" placeholder="请输入类型名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.typeName || '类型详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchOutputTypeList,
|
||||
fetchCreateOutputType,
|
||||
fetchUpdateOutputType,
|
||||
fetchDeleteOutputType
|
||||
} from '@/api/production'
|
||||
|
||||
defineOptions({ name: 'ProductionOutputType' })
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增类型')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
|
||||
const formData = reactive({
|
||||
typeCode: '',
|
||||
typeName: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
typeCode: [{ required: true, message: '请输入类型编码', trigger: 'blur' }],
|
||||
typeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '类型编码', value: row.typeCode },
|
||||
{ label: '类型名称', value: row.typeName },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchOutputTypeList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.typeCode = ''
|
||||
formData.typeName = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增类型'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑类型'
|
||||
editingId.value = row.id
|
||||
formData.typeCode = row.typeCode || ''
|
||||
formData.typeName = row.typeName || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
typeCode: formData.typeCode.trim(),
|
||||
typeName: formData.typeName.trim(),
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateOutputType(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateOutputType(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteOutputType(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个类型吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteOutputType(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.output-type-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<!-- 工艺工序分类 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>工艺工序分类</h1>
|
||||
<p class="page-desc">维护工艺工序的分类编码与名称。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">分类列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="分类编码/名称" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编码、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增分类
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="categoryCode" label="分类编码" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="categoryName" label="分类名称" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="process-category-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="分类编码" prop="categoryCode">
|
||||
<el-input v-model="formData.categoryCode" placeholder="请输入分类编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类名称" prop="categoryName">
|
||||
<el-input v-model="formData.categoryName" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.categoryName || '分类详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchProcessCategoryList,
|
||||
fetchCreateProcessCategory,
|
||||
fetchUpdateProcessCategory,
|
||||
fetchDeleteProcessCategory
|
||||
} from '@/api/production'
|
||||
|
||||
defineOptions({ name: 'ProductionProcessCategory' })
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增分类')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
|
||||
const formData = reactive({
|
||||
categoryCode: '',
|
||||
categoryName: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
categoryCode: [{ required: true, message: '请输入分类编码', trigger: 'blur' }],
|
||||
categoryName: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '分类编码', value: row.categoryCode },
|
||||
{ label: '分类名称', value: row.categoryName },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchProcessCategoryList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.categoryCode = ''
|
||||
formData.categoryName = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增分类'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑分类'
|
||||
editingId.value = row.id
|
||||
formData.categoryCode = row.categoryCode || ''
|
||||
formData.categoryName = row.categoryName || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
categoryCode: formData.categoryCode.trim(),
|
||||
categoryName: formData.categoryName.trim(),
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateProcessCategory(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateProcessCategory(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该分类吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteProcessCategory(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个分类吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteProcessCategory(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.process-category-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,891 @@
|
||||
<!-- 工艺库管理 -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>工艺库管理</h1>
|
||||
<p class="page-desc">管理工艺工序及其关联的试剂、耗材与产出物配置。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">工艺列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="工序代码/名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按工序代码、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增工艺
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="processCode"
|
||||
label="工序代码"
|
||||
min-width="130"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="processName"
|
||||
label="工序名称"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="工序分类"
|
||||
min-width="130"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ categoryMap[row.categoryId] || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="description"
|
||||
label="描述"
|
||||
min-width="160"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="equipmentName"
|
||||
label="设备名称"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="estimatedDuration"
|
||||
label="预估耗时"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="1000px"
|
||||
class="dialog-form-layout process-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="process-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- Tab 1: 基本信息 -->
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="工序代码" prop="processCode">
|
||||
<el-input v-model="formData.processCode" placeholder="请输入工序代码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工序名称" prop="processName">
|
||||
<el-input v-model="formData.processName" placeholder="请输入工序名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工序分类">
|
||||
<el-select
|
||||
v-model="formData.categoryId"
|
||||
placeholder="请选择分类"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="cat in categoryOptions"
|
||||
:key="cat.id"
|
||||
:label="cat.categoryName || cat.name"
|
||||
:value="cat.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备名称">
|
||||
<el-input v-model="formData.equipmentName" placeholder="请输入设备名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预估耗时">
|
||||
<el-input v-model="formData.estimatedDuration" placeholder="如 30min" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关联SOP">
|
||||
<el-select
|
||||
v-model="formData.sopIds"
|
||||
placeholder="请选择SOP文档"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="sop in sopOptions"
|
||||
:key="sop.id"
|
||||
:label="sop.sopName || sop.name"
|
||||
:value="sop.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" class="form-col-span-2">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 试剂关联 -->
|
||||
<el-tab-pane label="试剂关联" name="reagents">
|
||||
<div class="tab-toolbar">
|
||||
<el-button class="btn-main" type="primary" size="small" @click="openAddReagentDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加试剂
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="reagentList" stripe size="small" class="inner-table">
|
||||
<el-table-column prop="reagentCode" label="试剂编码" min-width="110" align="center" header-align="center" />
|
||||
<el-table-column prop="reagentName" label="试剂名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="spec" label="规格" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column label="标准用量" min-width="110" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
|
||||
<el-table-column label="关键物料" width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.isKeyMaterial" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" header-align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="reagentList.splice($index, 1)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="reagentList.length === 0" description="暂无试剂,点击上方按钮添加" :image-size="48" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 耗材关联 -->
|
||||
<el-tab-pane label="耗材关联" name="consumables">
|
||||
<div class="tab-toolbar">
|
||||
<div class="tab-toolbar-left">
|
||||
<el-button class="btn-main" type="primary" size="small" @click="openAddConsumableDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加耗材
|
||||
</el-button>
|
||||
<el-select
|
||||
v-model="selectedPackId"
|
||||
placeholder="从耗材包导入"
|
||||
clearable
|
||||
filterable
|
||||
size="small"
|
||||
style="width: 200px"
|
||||
@change="handleImportPack"
|
||||
>
|
||||
<el-option
|
||||
v-for="pack in packOptions"
|
||||
:key="pack.id"
|
||||
:label="pack.packName"
|
||||
:value="pack.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="consumableList" stripe size="small" class="inner-table">
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="110" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="110" align="center" header-align="center" />
|
||||
<el-table-column label="标准用量" min-width="110" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.standardUsage" placeholder="用量" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="必填" width="80" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.isRequired" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" placeholder="备注" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" header-align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="consumableList.splice($index, 1)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="consumableList.length === 0" description="暂无耗材,点击上方按钮添加" :image-size="48" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 产出物 -->
|
||||
<el-tab-pane label="产出物" name="outputs">
|
||||
<div class="tab-toolbar">
|
||||
<el-button class="btn-main" type="primary" size="small" @click="addOutputRow">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加产出物
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="outputList" stripe size="small" class="inner-table">
|
||||
<el-table-column label="产出物类型" min-width="150" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.outputTypeId" placeholder="选择类型" size="small" filterable>
|
||||
<el-option
|
||||
v-for="ot in outputTypeOptions"
|
||||
:key="ot.id"
|
||||
:label="ot.typeName || ot.name"
|
||||
:value="ot.id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产出物名称" min-width="150" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.outputName" placeholder="名称" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产出物条码" min-width="150" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.outputBarcode" placeholder="条码" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" header-align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="outputList.splice($index, 1)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="outputList.length === 0" description="暂无产出物,点击上方按钮添加" :image-size="48" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 选择试剂内弹窗 -->
|
||||
<el-dialog
|
||||
v-model="selectReagentVisible"
|
||||
title="选择试剂"
|
||||
width="700px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-input
|
||||
v-model="reagentKeyword"
|
||||
placeholder="搜索试剂编码/名称"
|
||||
clearable
|
||||
style="margin-bottom: 12px"
|
||||
@input="filterReagents"
|
||||
/>
|
||||
<el-table
|
||||
:data="filteredReagentList"
|
||||
stripe
|
||||
size="small"
|
||||
height="360"
|
||||
highlight-current-row
|
||||
@current-change="handleReagentSelect"
|
||||
>
|
||||
<el-table-column prop="reagentCode" label="试剂编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="reagentName" label="试剂名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="spec" label="规格" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column prop="unit" label="单位" min-width="80" align="center" header-align="center" />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="selectReagentVisible = false">取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :disabled="!selectedReagent" @click="confirmAddReagent">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 选择耗材内弹窗 -->
|
||||
<el-dialog
|
||||
v-model="selectConsumableVisible"
|
||||
title="选择耗材"
|
||||
width="700px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-input
|
||||
v-model="consumableKeyword"
|
||||
placeholder="搜索耗材编码/名称"
|
||||
clearable
|
||||
style="margin-bottom: 12px"
|
||||
@input="filterConsumables"
|
||||
/>
|
||||
<el-table
|
||||
:data="filteredConsumableList"
|
||||
stripe
|
||||
size="small"
|
||||
height="360"
|
||||
highlight-current-row
|
||||
@current-change="handleConsumableRowSelect"
|
||||
>
|
||||
<el-table-column prop="consumableCode" label="耗材编码" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="consumableName" label="耗材名称" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="specModel" label="规格型号" min-width="120" align="center" header-align="center" />
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="selectConsumableVisible = false">取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :disabled="!selectedConsumable" @click="confirmAddConsumable">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Close, Check } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchProcessList,
|
||||
fetchProcessDetail,
|
||||
fetchCreateProcess,
|
||||
fetchUpdateProcess,
|
||||
fetchDeleteProcess,
|
||||
fetchAllProcessCategories,
|
||||
fetchAllSopDocuments,
|
||||
fetchAllReagents,
|
||||
fetchAllConsumables,
|
||||
fetchAllOutputTypes,
|
||||
fetchAllConsumablePacks
|
||||
} from '@/api/production'
|
||||
|
||||
defineOptions({ name: 'ProductionProcess' })
|
||||
|
||||
// 搜索 & 列表
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
// 弹窗状态
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增工艺')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const activeTab = ref('basic')
|
||||
|
||||
// 下拉选项
|
||||
const categoryOptions = ref<any[]>([])
|
||||
const sopOptions = ref<any[]>([])
|
||||
const outputTypeOptions = ref<any[]>([])
|
||||
const packOptions = ref<any[]>([])
|
||||
const allReagentOptions = ref<any[]>([])
|
||||
const allConsumableOptions = ref<any[]>([])
|
||||
|
||||
// 列表中的分类映射
|
||||
const categoryMap = computed(() => {
|
||||
const map: Record<string, string> = {}
|
||||
categoryOptions.value.forEach((c: any) => {
|
||||
map[c.id] = c.categoryName || c.name || ''
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
processCode: '',
|
||||
processName: '',
|
||||
categoryId: '',
|
||||
description: '',
|
||||
equipmentName: '',
|
||||
estimatedDuration: '',
|
||||
sopIds: [] as string[],
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
processCode: [{ required: true, message: '请输入工序代码', trigger: 'blur' }],
|
||||
processName: [{ required: true, message: '请输入工序名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 子表数据
|
||||
const reagentList = ref<any[]>([])
|
||||
const consumableList = ref<any[]>([])
|
||||
const outputList = ref<any[]>([])
|
||||
|
||||
// 选择试剂弹窗
|
||||
const selectReagentVisible = ref(false)
|
||||
const filteredReagentList = ref<any[]>([])
|
||||
const reagentKeyword = ref('')
|
||||
const selectedReagent = ref<any>(null)
|
||||
|
||||
// 选择耗材弹窗
|
||||
const selectConsumableVisible = ref(false)
|
||||
const filteredConsumableList = ref<any[]>([])
|
||||
const consumableKeyword = ref('')
|
||||
const selectedConsumable = ref<any>(null)
|
||||
|
||||
// 耗材包导入
|
||||
const selectedPackId = ref<string | undefined>(undefined)
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchProcessList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有下拉参考数据
|
||||
async function loadReferenceData() {
|
||||
try {
|
||||
const [cats, sops, reagents, consumables, outputTypes, packs] = await Promise.all([
|
||||
fetchAllProcessCategories(),
|
||||
fetchAllSopDocuments(),
|
||||
fetchAllReagents(),
|
||||
fetchAllConsumables(),
|
||||
fetchAllOutputTypes(),
|
||||
fetchAllConsumablePacks()
|
||||
])
|
||||
categoryOptions.value = cats || []
|
||||
sopOptions.value = sops || []
|
||||
allReagentOptions.value = reagents || []
|
||||
allConsumableOptions.value = consumables || []
|
||||
outputTypeOptions.value = outputTypes || []
|
||||
packOptions.value = packs || []
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.processCode = ''
|
||||
formData.processName = ''
|
||||
formData.categoryId = ''
|
||||
formData.description = ''
|
||||
formData.equipmentName = ''
|
||||
formData.estimatedDuration = ''
|
||||
formData.sopIds = []
|
||||
formData.remark = ''
|
||||
reagentList.value = []
|
||||
consumableList.value = []
|
||||
outputList.value = []
|
||||
editingId.value = null
|
||||
activeTab.value = 'basic'
|
||||
selectedPackId.value = undefined
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增工艺'
|
||||
resetForm()
|
||||
await loadReferenceData()
|
||||
dialogVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
async function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑工艺'
|
||||
editingId.value = row.id
|
||||
resetForm()
|
||||
await loadReferenceData()
|
||||
try {
|
||||
const detail = await fetchProcessDetail(row.id)
|
||||
formData.processCode = detail.processCode || ''
|
||||
formData.processName = detail.processName || ''
|
||||
formData.categoryId = detail.categoryId || ''
|
||||
formData.description = detail.description || ''
|
||||
formData.equipmentName = detail.equipmentName || ''
|
||||
formData.estimatedDuration = detail.estimatedDuration || ''
|
||||
formData.sopIds = detail.sopIds || []
|
||||
formData.remark = detail.remark || ''
|
||||
reagentList.value = (detail.reagents || []).map((r: any) => ({ ...r }))
|
||||
consumableList.value = (detail.consumables || []).map((c: any) => ({ ...c }))
|
||||
outputList.value = (detail.outputs || []).map((o: any) => ({ ...o }))
|
||||
} catch {
|
||||
ElMessage.error('加载详情失败')
|
||||
}
|
||||
dialogVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate?.())
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
processCode: formData.processCode.trim(),
|
||||
processName: formData.processName.trim(),
|
||||
categoryId: formData.categoryId || undefined,
|
||||
description: formData.description?.trim() || undefined,
|
||||
equipmentName: formData.equipmentName?.trim() || undefined,
|
||||
estimatedDuration: formData.estimatedDuration?.trim() || undefined,
|
||||
sopIds: formData.sopIds,
|
||||
remark: formData.remark?.trim() || undefined,
|
||||
reagents: reagentList.value.map((r) => ({
|
||||
reagentId: r.reagentId || r.id,
|
||||
reagentCode: r.reagentCode,
|
||||
reagentName: r.reagentName,
|
||||
spec: r.spec,
|
||||
standardUsage: r.standardUsage,
|
||||
unit: r.unit,
|
||||
isKeyMaterial: r.isKeyMaterial || false
|
||||
})),
|
||||
consumables: consumableList.value.map((c) => ({
|
||||
consumableId: c.consumableId || c.id,
|
||||
consumableCode: c.consumableCode,
|
||||
consumableName: c.consumableName,
|
||||
specModel: c.specModel,
|
||||
standardUsage: c.standardUsage,
|
||||
isRequired: c.isRequired || false,
|
||||
remark: c.remark || ''
|
||||
})),
|
||||
outputs: outputList.value.map((o) => ({
|
||||
outputTypeId: o.outputTypeId,
|
||||
outputName: o.outputName,
|
||||
outputBarcode: o.outputBarcode
|
||||
}))
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateProcess(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateProcess(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该工艺吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteProcess(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个工艺吗?`, '提示', { type: 'warning' })
|
||||
for (const id of ids) {
|
||||
await fetchDeleteProcess(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// =============== 试剂弹窗 ===============
|
||||
function openAddReagentDialog() {
|
||||
selectedReagent.value = null
|
||||
reagentKeyword.value = ''
|
||||
filteredReagentList.value = [...allReagentOptions.value]
|
||||
selectReagentVisible.value = true
|
||||
}
|
||||
|
||||
function filterReagents() {
|
||||
const kw = reagentKeyword.value.trim().toLowerCase()
|
||||
if (!kw) {
|
||||
filteredReagentList.value = [...allReagentOptions.value]
|
||||
return
|
||||
}
|
||||
filteredReagentList.value = allReagentOptions.value.filter(
|
||||
(r) =>
|
||||
(r.reagentCode || '').toLowerCase().includes(kw) ||
|
||||
(r.reagentName || '').toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
function handleReagentSelect(row: any) {
|
||||
selectedReagent.value = row
|
||||
}
|
||||
|
||||
function confirmAddReagent() {
|
||||
if (!selectedReagent.value) return
|
||||
const r = selectedReagent.value
|
||||
const exists = reagentList.value.some(
|
||||
(item) => item.reagentId === r.id || item.reagentCode === r.reagentCode
|
||||
)
|
||||
if (exists) {
|
||||
ElMessage.warning('该试剂已在列表中')
|
||||
return
|
||||
}
|
||||
reagentList.value.push({
|
||||
reagentId: r.id,
|
||||
reagentCode: r.reagentCode || '',
|
||||
reagentName: r.reagentName || '',
|
||||
spec: r.spec || '',
|
||||
standardUsage: '',
|
||||
unit: r.unit || '',
|
||||
isKeyMaterial: false
|
||||
})
|
||||
selectReagentVisible.value = false
|
||||
}
|
||||
|
||||
// =============== 耗材弹窗 ===============
|
||||
function openAddConsumableDialog() {
|
||||
selectedConsumable.value = null
|
||||
consumableKeyword.value = ''
|
||||
filteredConsumableList.value = [...allConsumableOptions.value]
|
||||
selectConsumableVisible.value = true
|
||||
}
|
||||
|
||||
function filterConsumables() {
|
||||
const kw = consumableKeyword.value.trim().toLowerCase()
|
||||
if (!kw) {
|
||||
filteredConsumableList.value = [...allConsumableOptions.value]
|
||||
return
|
||||
}
|
||||
filteredConsumableList.value = allConsumableOptions.value.filter(
|
||||
(c) =>
|
||||
(c.consumableCode || '').toLowerCase().includes(kw) ||
|
||||
(c.consumableName || '').toLowerCase().includes(kw)
|
||||
)
|
||||
}
|
||||
|
||||
function handleConsumableRowSelect(row: any) {
|
||||
selectedConsumable.value = row
|
||||
}
|
||||
|
||||
function confirmAddConsumable() {
|
||||
if (!selectedConsumable.value) return
|
||||
const c = selectedConsumable.value
|
||||
const exists = consumableList.value.some(
|
||||
(item) => item.consumableId === c.id || item.consumableCode === c.consumableCode
|
||||
)
|
||||
if (exists) {
|
||||
ElMessage.warning('该耗材已在列表中')
|
||||
return
|
||||
}
|
||||
consumableList.value.push({
|
||||
consumableId: c.id,
|
||||
consumableCode: c.consumableCode || '',
|
||||
consumableName: c.consumableName || '',
|
||||
specModel: c.specModel || '',
|
||||
standardUsage: '',
|
||||
isRequired: false,
|
||||
remark: ''
|
||||
})
|
||||
selectConsumableVisible.value = false
|
||||
}
|
||||
|
||||
// =============== 从耗材包导入 ===============
|
||||
async function handleImportPack(packId: string | undefined) {
|
||||
if (!packId) return
|
||||
// 在 packOptions 中找到对应的包,然后查看它的 items
|
||||
// 由于 packOptions 来自 fetchAllConsumablePacks,可能不含 items
|
||||
// 需要用 fetchConsumablePackDetail 加载详情
|
||||
try {
|
||||
const { fetchConsumablePackDetail } = await import('@/api/production')
|
||||
const detail = await fetchConsumablePackDetail(packId)
|
||||
const packItems = detail.items || []
|
||||
let added = 0
|
||||
for (const item of packItems) {
|
||||
const exists = consumableList.value.some(
|
||||
(c) => c.consumableId === item.consumableId || c.consumableCode === item.consumableCode
|
||||
)
|
||||
if (!exists) {
|
||||
consumableList.value.push({
|
||||
consumableId: item.consumableId,
|
||||
consumableCode: item.consumableCode || '',
|
||||
consumableName: item.consumableName || '',
|
||||
specModel: item.specModel || '',
|
||||
standardUsage: item.standardUsage || '',
|
||||
isRequired: false,
|
||||
remark: ''
|
||||
})
|
||||
added++
|
||||
}
|
||||
}
|
||||
ElMessage.success(`导入成功,新增 ${added} 项耗材`)
|
||||
} catch {
|
||||
ElMessage.error('导入耗材包失败')
|
||||
}
|
||||
selectedPackId.value = undefined
|
||||
}
|
||||
|
||||
// =============== 产出物 ===============
|
||||
function addOutputRow() {
|
||||
outputList.value.push({
|
||||
outputTypeId: '',
|
||||
outputName: '',
|
||||
outputBarcode: ''
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化加载列表 & 分类数据(用于列表列的分类名映射)
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
// 静默加载分类数据用于列表展示
|
||||
try {
|
||||
categoryOptions.value = (await fetchAllProcessCategories()) || []
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.process-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.process-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tab-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-col-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
<!-- 试剂管理 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>试剂管理</h1>
|
||||
<p class="page-desc">维护生产试剂的编码、名称、规格、标准用量及单位等信息。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">试剂列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="试剂编码/名称" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编码、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增试剂
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="reagentCode" label="试剂编码" min-width="130" align="center" header-align="center" />
|
||||
<el-table-column prop="reagentName" label="试剂名称" min-width="150" align="center" header-align="center" />
|
||||
<el-table-column prop="spec" label="规格" min-width="120" align="center" header-align="center" />
|
||||
<el-table-column prop="standardUsage" label="标准用量" min-width="110" align="center" header-align="center" />
|
||||
<el-table-column prop="unit" label="单位" min-width="90" align="center" header-align="center" />
|
||||
<el-table-column label="描述" min-width="160" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ truncateText(row.description, 20) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="reagent-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="试剂编码" prop="reagentCode">
|
||||
<el-input v-model="formData.reagentCode" placeholder="请输入试剂编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="试剂名称" prop="reagentName">
|
||||
<el-input v-model="formData.reagentName" placeholder="请输入试剂名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格">
|
||||
<el-input v-model="formData.spec" placeholder="请输入规格" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标准用量">
|
||||
<el-input v-model="formData.standardUsage" placeholder="请输入标准用量" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-select v-model="formData.unit" placeholder="请选择单位" clearable filterable>
|
||||
<el-option
|
||||
v-for="item in unitOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="formData.description" placeholder="请输入描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.reagentName || '试剂详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, ScaleToOriginal } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchReagentList,
|
||||
fetchCreateReagent,
|
||||
fetchUpdateReagent,
|
||||
fetchDeleteReagent
|
||||
} from '@/api/production'
|
||||
import { fetchItemUnitList } from '@/api/admin'
|
||||
|
||||
defineOptions({ name: 'ProductionReagent' })
|
||||
|
||||
function truncateText(text: string, max: number) {
|
||||
if (!text) return ''
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
|
||||
const unitOptions = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增试剂')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
|
||||
const formData = reactive({
|
||||
reagentCode: '',
|
||||
reagentName: '',
|
||||
spec: '',
|
||||
standardUsage: '',
|
||||
unit: '',
|
||||
description: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
reagentCode: [{ required: true, message: '请输入试剂编码', trigger: 'blur' }],
|
||||
reagentName: [{ required: true, message: '请输入试剂名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '试剂编码', value: row.reagentCode },
|
||||
{ label: '试剂名称', value: row.reagentName },
|
||||
{ label: '规格', value: row.spec },
|
||||
{ label: '标准用量', value: row.standardUsage },
|
||||
{ label: '单位', value: row.unit },
|
||||
{ label: '描述', value: row.description, span: true },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchReagentList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.reagentCode = ''
|
||||
formData.reagentName = ''
|
||||
formData.spec = ''
|
||||
formData.standardUsage = ''
|
||||
formData.unit = ''
|
||||
formData.description = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增试剂'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑试剂'
|
||||
editingId.value = row.id
|
||||
formData.reagentCode = row.reagentCode || ''
|
||||
formData.reagentName = row.reagentName || ''
|
||||
formData.spec = row.spec || ''
|
||||
formData.standardUsage = row.standardUsage ?? ''
|
||||
formData.unit = row.unit || ''
|
||||
formData.description = row.description || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
reagentCode: formData.reagentCode.trim(),
|
||||
reagentName: formData.reagentName.trim(),
|
||||
spec: formData.spec?.trim() || undefined,
|
||||
standardUsage: formData.standardUsage ?? undefined,
|
||||
unit: formData.unit?.trim() || undefined,
|
||||
description: formData.description?.trim() || undefined,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateReagent(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateReagent(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该试剂吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteReagent(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个试剂吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteReagent(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadData()
|
||||
const res = await fetchItemUnitList({ page: 1, pageSize: 100 })
|
||||
unitOptions.value = (res.list || []).map((u: any) => ({ label: u.unitName, value: u.unitName }))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.reagent-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,492 @@
|
||||
<!-- SOP文档 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>SOP文档</h1>
|
||||
<p class="page-desc">管理标准作业程序文档,支持附件上传与版本管理。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">SOP列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="SOP编号/名称" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">按编号、名称模糊查询。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增SOP
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="sopCode" label="SOP编号" min-width="130" align="center" header-align="center" />
|
||||
<el-table-column prop="sopName" label="SOP文档名称" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column prop="versionNo" label="版本号" min-width="100" align="center" header-align="center" />
|
||||
<el-table-column label="附件数" min-width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.sopAttachments ? row.sopAttachments.split(',').length : 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="sop-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="SOP编号" prop="sopCode">
|
||||
<el-input v-model="formData.sopCode" placeholder="请输入SOP编号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SOP文档名称" prop="sopName">
|
||||
<el-input v-model="formData.sopName" placeholder="请输入SOP文档名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版本号">
|
||||
<el-input v-model="formData.versionNo" placeholder="请输入版本号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="附件" class="form-col-span-2">
|
||||
<div class="upload-card-row">
|
||||
<div
|
||||
v-for="(url, idx) in attachmentList"
|
||||
:key="idx"
|
||||
class="upload-card-item"
|
||||
>
|
||||
<el-icon class="upload-card-icon"><Document /></el-icon>
|
||||
<span class="upload-card-name" :title="getFileName(url)">{{ getFileName(url) }}</span>
|
||||
<el-icon class="upload-card-remove" @click="removeAttachment(idx)"><Close /></el-icon>
|
||||
</div>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
accept="*"
|
||||
>
|
||||
<div class="upload-card-trigger">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>上传</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div class="upload-card-hint">支持上传多个附件文件。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
:title="viewRow?.sopName || 'SOP详情'"
|
||||
:icon="ScaleToOriginal"
|
||||
:meta="viewRow?.createTime || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, View, ScaleToOriginal, Document } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchSopList,
|
||||
fetchCreateSop,
|
||||
fetchUpdateSop,
|
||||
fetchDeleteSop
|
||||
} from '@/api/production'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
|
||||
defineOptions({ name: 'ProductionSop' })
|
||||
|
||||
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
|
||||
const tableData = ref<any[]>([])
|
||||
const selectedRows = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增SOP')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const viewVisible = ref(false)
|
||||
const viewRow = ref<any>(null)
|
||||
|
||||
const formData = reactive({
|
||||
sopCode: '',
|
||||
sopName: '',
|
||||
versionNo: '',
|
||||
sopAttachments: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const attachmentList = computed(() => {
|
||||
if (!formData.sopAttachments) return []
|
||||
return formData.sopAttachments.split(',').filter(Boolean)
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
sopCode: [{ required: true, message: '请输入SOP编号', trigger: 'blur' }],
|
||||
sopName: [{ required: true, message: '请输入SOP文档名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function getFileName(url: string) {
|
||||
if (!url) return ''
|
||||
const parts = url.split('/')
|
||||
return decodeURIComponent(parts[parts.length - 1] || '')
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
const viewSections = computed(() => {
|
||||
if (!viewRow.value) return []
|
||||
const row = viewRow.value
|
||||
const attachCount = row.sopAttachments ? row.sopAttachments.split(',').filter(Boolean).length : 0
|
||||
const attachLinks = row.sopAttachments
|
||||
? row.sopAttachments
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((url: string) => getFileName(url))
|
||||
.join('、')
|
||||
: '无'
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: 'SOP编号', value: row.sopCode },
|
||||
{ label: 'SOP文档名称', value: row.sopName },
|
||||
{ label: '版本号', value: row.versionNo },
|
||||
{ label: '附件数', value: String(attachCount) },
|
||||
{ label: '附件列表', value: attachLinks, span: true },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function handleFileChange(uploadFile: any) {
|
||||
const file = uploadFile?.raw
|
||||
if (!file) return
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('business_type', 'sop')
|
||||
fd.append('business_id', editingId.value || 'temp')
|
||||
fd.append('file', file)
|
||||
const res: any = await fetchUploadAttachment(fd)
|
||||
if (res?.fileUrl) {
|
||||
const current = formData.sopAttachments ? formData.sopAttachments.split(',').filter(Boolean) : []
|
||||
current.push(res.fileUrl)
|
||||
formData.sopAttachments = current.join(',')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('文件上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(idx: number) {
|
||||
const current = formData.sopAttachments.split(',').filter(Boolean)
|
||||
current.splice(idx, 1)
|
||||
formData.sopAttachments = current.join(',')
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchSopList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.sopCode = ''
|
||||
formData.sopName = ''
|
||||
formData.versionNo = ''
|
||||
formData.sopAttachments = ''
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增SOP'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
function handleEdit(row: any) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑SOP'
|
||||
editingId.value = row.id
|
||||
formData.sopCode = row.sopCode || ''
|
||||
formData.sopName = row.sopName || ''
|
||||
formData.versionNo = row.versionNo || ''
|
||||
formData.sopAttachments = row.sopAttachments || ''
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value?.validate?.()
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
sopCode: formData.sopCode.trim(),
|
||||
sopName: formData.sopName.trim(),
|
||||
versionNo: formData.versionNo?.trim() || undefined,
|
||||
sopAttachments: formData.sopAttachments || undefined,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateSop(editingId.value, payload)
|
||||
} else {
|
||||
await fetchCreateSop(payload)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch {
|
||||
/* 校验或接口错误已由全局处理 */
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm('确定要删除该SOP文档吗?', '提示', { type: 'warning' })
|
||||
await fetchDeleteSop(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedRows.value.map((r) => r.id)
|
||||
if (!ids.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 个SOP文档吗?`, '提示', { type: 'warning' })
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const id of ids) {
|
||||
await fetchDeleteSop(id)
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
await loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sop-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-card-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.upload-card-item {
|
||||
width: 120px;
|
||||
height: 96px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.upload-card-icon {
|
||||
font-size: 24px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-card-name {
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-card-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
font-size: 14px;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-card-trigger {
|
||||
width: 120px;
|
||||
height: 96px;
|
||||
border: 1px dashed #a7f3d0;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-card-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,339 @@
|
||||
<!-- 捐赠协议单审核 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>捐赠协议审核</h1>
|
||||
<p class="page-desc">审核与管理科研捐赠协议数据。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">捐赠协议审核</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/母亲姓名" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-select
|
||||
v-model="searchForm.auditStatus"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="审核通过" value="shtg" />
|
||||
<el-option label="审核驳回" value="shth" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">列表为概要信息,详情见弹窗。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset"
|
||||
><el-icon><RefreshLeft /></el-icon>重置</el-button
|
||||
>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch"
|
||||
><el-icon><Search /></el-icon>搜索</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shtg')">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
批量审核通过
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchAudit('shth')">
|
||||
<el-icon><CircleClose /></el-icon>
|
||||
批量审核驳回
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('activate')">
|
||||
批量生效
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchStatusChange('void')">
|
||||
批量作废
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="donationNo"
|
||||
label="捐赠编号"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="motherName"
|
||||
label="母亲姓名"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="auditStatus"
|
||||
label="审核状态"
|
||||
width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.auditStatus === 'shtg' ? 'success' : row.auditStatus === 'shth' ? 'danger' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ auditStatusLabel(String(row.auditStatus || '')) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="auditRemark"
|
||||
label="审核备注"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="auditTime"
|
||||
label="审核时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
min-width="260"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="openAuditDialog(row)"
|
||||
><el-icon><CircleCheck /></el-icon>审核</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看弹窗 -->
|
||||
<DonationViewDialog
|
||||
v-model="viewVisible"
|
||||
:donation-id="viewDonationId"
|
||||
:list-row="viewRow"
|
||||
show-audit-history
|
||||
/>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<DonationAuditDialog
|
||||
v-model="auditDialogVisible"
|
||||
:donation-id="auditDonationId"
|
||||
:list-row="auditRow"
|
||||
@saved="onAuditSubmitted"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
View,
|
||||
Search,
|
||||
RefreshLeft,
|
||||
CircleCheck,
|
||||
CircleClose
|
||||
} from '@element-plus/icons-vue'
|
||||
import DonationViewDialog from '../list/DonationViewDialog.vue'
|
||||
import DonationAuditDialog from '../list/DonationAuditDialog.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchResearchDonationAuditList,
|
||||
fetchSubmitDonationAudit,
|
||||
fetchDonationStatusChange,
|
||||
type ResearchDonationRow
|
||||
} from '@/api/research-donation'
|
||||
|
||||
defineOptions({ name: 'ResearchDonationAudit' })
|
||||
|
||||
/* ====== 标签映射 ====== */
|
||||
function auditStatusLabel(s: string) {
|
||||
if (s === 'shtg') return '审核通过'
|
||||
if (s === 'shth') return '审核驳回'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
type AuditRow = Record<string, unknown>
|
||||
|
||||
const searchForm = reactive<{ keyword: string; auditStatus: string }>({
|
||||
keyword: '',
|
||||
auditStatus: ''
|
||||
})
|
||||
const tableData = ref<AuditRow[]>([])
|
||||
const selectedRows = ref<AuditRow[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const handleSelectionChange = (rows: AuditRow[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchResearchDonationAuditList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
auditStatus: searchForm.auditStatus || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.auditStatus = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 查看弹窗 ====== */
|
||||
const viewVisible = ref(false)
|
||||
const viewDonationId = ref<string | null>(null)
|
||||
const viewRow = ref<AuditRow | null>(null)
|
||||
|
||||
function handleView(row: AuditRow) {
|
||||
viewDonationId.value = String(row.id)
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
/* ====== 审核 ====== */
|
||||
const auditDialogVisible = ref(false)
|
||||
const auditDonationId = ref<string | null>(null)
|
||||
const auditRow = ref<AuditRow | null>(null)
|
||||
|
||||
function openAuditDialog(row: AuditRow) {
|
||||
auditDonationId.value = String(row.id)
|
||||
auditRow.value = row
|
||||
auditDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onAuditSubmitted() {
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 批量审核 ====== */
|
||||
async function handleBatchAudit(status: string) {
|
||||
if (!selectedRows.value.length) {
|
||||
ElMessage.warning('请先选择要操作的记录')
|
||||
return
|
||||
}
|
||||
const label = status === 'shtg' ? '审核通过' : '审核驳回'
|
||||
await ElMessageBox.confirm(
|
||||
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
|
||||
'批量审核',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const rows = [...selectedRows.value]
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const row of rows) {
|
||||
await fetchSubmitDonationAudit(String(row.id), { auditStatus: status })
|
||||
}
|
||||
ElMessage.success(`批量${label}成功`)
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}, fullscreenLoadingText.batchAudit)
|
||||
}
|
||||
|
||||
/* ====== 批量状态变更 ====== */
|
||||
async function handleBatchStatusChange(action: 'activate' | 'void') {
|
||||
if (!selectedRows.value.length) {
|
||||
ElMessage.warning('请先选择要操作的记录')
|
||||
return
|
||||
}
|
||||
const label = action === 'activate' ? '生效' : '作废'
|
||||
const targetStatus = action === 'activate' ? 'sxz' : 'yzf'
|
||||
await ElMessageBox.confirm(
|
||||
`确认将选中的 ${selectedRows.value.length} 条记录标记为「${label}」?`,
|
||||
`批量${label}`,
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
const rows = [...selectedRows.value]
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const row of rows) {
|
||||
await fetchDonationStatusChange(String(row.id), { donationStatus: targetStatus })
|
||||
}
|
||||
ElMessage.success(`批量${label}成功`)
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}, fullscreenLoadingText.save)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!-- 捐赠协议单 — 审核弹窗 -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<p class="audit-target-hint">
|
||||
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
|
||||
</p>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="biz-dialog-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="审核状态" prop="auditStatus">
|
||||
<el-radio-group v-model="form.auditStatus">
|
||||
<el-radio value="shtg">审核通过</el-radio>
|
||||
<el-radio value="shth">审核驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核备注">
|
||||
<el-input
|
||||
v-model="form.auditRemark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可选"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { fetchSubmitDonationAudit } from '@/api/research-donation'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
defineOptions({ name: 'DonationAuditDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
donationId: string | null
|
||||
listRow?: Record<string, unknown> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
auditStatus: 'shtg',
|
||||
auditRemark: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
auditStatus: [{ required: true, message: '请选择审核状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() => '捐赠协议单审核')
|
||||
|
||||
function resetForm() {
|
||||
form.auditStatus = 'shtg'
|
||||
form.auditRemark = ''
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !props.donationId) return
|
||||
|
||||
const payload = {
|
||||
auditStatus: form.auditStatus,
|
||||
auditRemark: form.auditRemark.trim() || undefined
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await withFullscreenLoading(
|
||||
async () => {
|
||||
await fetchSubmitDonationAudit(props.donationId!, payload)
|
||||
emit('update:modelValue', false)
|
||||
emit('saved')
|
||||
},
|
||||
fullscreenLoadingText.save
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audit-target-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,680 @@
|
||||
<!-- 捐赠协议单 - 新增/编辑弹窗 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑捐赠协议单' : '新增捐赠协议单'"
|
||||
width="1200px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
>
|
||||
<el-tabs v-model="activeTab" class="donation-form-tabs">
|
||||
<!-- ====== Tab 1: 基本信息 ====== -->
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<el-form
|
||||
ref="basicFormRef"
|
||||
:model="formData"
|
||||
:rules="basicRules"
|
||||
label-position="top"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="捐赠编号" prop="donationNo">
|
||||
<el-input v-model="formData.donationNo" placeholder="请输入捐赠编号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="捐赠日期">
|
||||
<el-date-picker
|
||||
v-model="formData.donationDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择捐赠日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="来源类型">
|
||||
<el-select v-model="formData.sourceType" placeholder="请选择" :disabled="isEdit" style="width: 100%">
|
||||
<el-option label="独立新建" value="direct" />
|
||||
<el-option label="从存储转来" value="from_storage" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ====== Tab 2: 当事人信息 ====== -->
|
||||
<el-tab-pane label="当事人信息" name="party">
|
||||
<el-form
|
||||
ref="partyFormRef"
|
||||
:model="formData.partyCustomer"
|
||||
label-position="top"
|
||||
>
|
||||
<h4 class="section-sub-title">母亲信息</h4>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="母亲姓名">
|
||||
<el-input v-model="formData.partyCustomer.motherName" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="formData.partyCustomer.motherPhone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号">
|
||||
<el-input v-model="formData.partyCustomer.motherIdCard" placeholder="请输入身份证号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="出生年月">
|
||||
<el-date-picker
|
||||
v-model="formData.partyCustomer.motherBirthYm"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="请选择"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="民族">
|
||||
<el-input v-model="formData.partyCustomer.motherEthnic" placeholder="请输入民族" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="formData.partyCustomer.motherAddress" placeholder="请输入地址" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<h4 class="section-sub-title">父亲信息</h4>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="父亲姓名">
|
||||
<el-input v-model="formData.partyCustomer.fatherName" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="formData.partyCustomer.fatherPhone" placeholder="请输入电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号">
|
||||
<el-input v-model="formData.partyCustomer.fatherIdCard" placeholder="请输入身份证号" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<h4 class="section-sub-title">新生儿信息</h4>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="新生儿姓名">
|
||||
<el-input v-model="formData.partyCustomer.newbornName" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号">
|
||||
<el-input v-model="formData.partyCustomer.newbornIdCard" placeholder="请输入身份证号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="出生时间">
|
||||
<el-date-picker
|
||||
v-model="formData.partyCustomer.newbornDeliveryTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-select v-model="formData.partyCustomer.newbornSex" placeholder="请选择" style="width: 100%">
|
||||
<el-option label="男" value="male" />
|
||||
<el-option label="女" value="female" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分娩方式">
|
||||
<el-select v-model="formData.partyCustomer.deliveryMode" placeholder="请选择" style="width: 100%">
|
||||
<el-option label="顺产" value="natural" />
|
||||
<el-option label="剖宫产" value="cesarean" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="胎次">
|
||||
<el-input-number
|
||||
v-model="formData.partyCustomer.birthOrder"
|
||||
:min="1"
|
||||
:max="20"
|
||||
placeholder="胎次"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="出生体重(g)">
|
||||
<el-input-number
|
||||
v-model="formData.partyCustomer.newbornWeightGram"
|
||||
:min="0"
|
||||
:max="99999"
|
||||
placeholder="克"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Apgar 1分钟">
|
||||
<el-input-number
|
||||
v-model="formData.partyCustomer.apgar1min"
|
||||
:min="0"
|
||||
:max="10"
|
||||
placeholder="0-10"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Apgar 5分钟">
|
||||
<el-input-number
|
||||
v-model="formData.partyCustomer.apgar5min"
|
||||
:min="0"
|
||||
:max="10"
|
||||
placeholder="0-10"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="采集人员">
|
||||
<el-input v-model="formData.partyCustomer.collectorUserId" placeholder="请选择采集人员" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ====== Tab 3: 采集信息 ====== -->
|
||||
<el-tab-pane label="采集信息" name="collection">
|
||||
<el-form
|
||||
ref="collectionFormRef"
|
||||
:model="formData.collectionInfo"
|
||||
label-position="top"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="区域">
|
||||
<el-input v-model="formData.collectionInfo.regionId" placeholder="请选择区域" />
|
||||
</el-form-item>
|
||||
<el-form-item label="医院">
|
||||
<el-input v-model="formData.collectionInfo.hospitalId" placeholder="请选择医院" />
|
||||
</el-form-item>
|
||||
<el-form-item label="医护人员">
|
||||
<el-input v-model="formData.collectionInfo.medicalStaff" placeholder="请输入医护人员姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="业务员">
|
||||
<el-input v-model="formData.collectionInfo.salesUserId" placeholder="请选择业务员" />
|
||||
</el-form-item>
|
||||
<el-form-item label="样本采集时间">
|
||||
<el-date-picker
|
||||
v-model="formData.collectionInfo.sampleCollectTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="母血采集时间">
|
||||
<el-date-picker
|
||||
v-model="formData.collectionInfo.maternalBloodCollectTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ====== Tab 4: 样本信息 ====== -->
|
||||
<el-tab-pane label="样本信息" name="sample">
|
||||
<div class="sample-toolbar">
|
||||
<el-button type="primary" class="btn-main" @click="addSample">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加样本
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-for="(sample, index) in formData.samples" :key="index" class="sample-block">
|
||||
<div class="sample-block-header">
|
||||
<span class="sample-block-title">样本 {{ index + 1 }}</span>
|
||||
<el-button type="danger" link @click="removeSample(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
移除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form :model="sample" label-position="top">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="样本类型">
|
||||
<el-input v-model="sample.sampleTypeId" placeholder="请选择样本类型" />
|
||||
</el-form-item>
|
||||
<el-form-item label="样本编码">
|
||||
<el-input v-model="sample.sampleCode" placeholder="请输入样本编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="采集量">
|
||||
<el-input-number
|
||||
v-model="sample.collectAmount"
|
||||
:min="0"
|
||||
placeholder="采集量"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="sample.unitId" placeholder="请选择单位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储年限">
|
||||
<el-input-number
|
||||
v-model="sample.storageYears"
|
||||
:min="0"
|
||||
placeholder="年"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<h5 class="section-sub-title">运输信息(选填)</h5>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="运输方式">
|
||||
<el-select v-model="sample.transportType" placeholder="请选择" clearable style="width: 100%">
|
||||
<el-option label="快递" value="express" />
|
||||
<el-option label="专人运送" value="dedicated" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递公司">
|
||||
<el-input v-model="sample.expressCompanyId" placeholder="请选择快递公司" />
|
||||
</el-form-item>
|
||||
<el-form-item label="运单号">
|
||||
<el-input v-model="sample.waybillNo" placeholder="请输入运单号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="运费">
|
||||
<el-input-number
|
||||
v-model="sample.expressFee"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
placeholder="运费金额"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="承运人姓名">
|
||||
<el-input v-model="sample.carrierName" placeholder="请输入承运人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="承运人电话">
|
||||
<el-input v-model="sample.carrierPhone" placeholder="请输入承运人电话" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!formData.samples.length" description="暂未添加样本" :image-size="64" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ====== Tab 5: 附件 ====== -->
|
||||
<el-tab-pane label="附件" name="attachment">
|
||||
<div class="attachment-section">
|
||||
<h4 class="section-sub-title">
|
||||
知情同意书 <span class="text-danger">*</span>
|
||||
</h4>
|
||||
<el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:on-change="(file: UploadFile, fileList: UploadFile[]) => handleConsentChange(fileList)"
|
||||
:on-remove="() => handleConsentRemove()"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
>
|
||||
<el-button type="primary" class="btn-main">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传知情同意书
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<p class="form-label-tip">支持图片和PDF格式,可上传多份。</p>
|
||||
<div v-if="consentPreviewUrls.length" class="attachment-preview-list">
|
||||
<span v-for="(url, idx) in consentPreviewUrls" :key="idx" class="attachment-preview-item">
|
||||
{{ extractFileName(url) }}
|
||||
<el-button type="danger" link size="small" @click="removeConsentFile(idx)">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attachment-section">
|
||||
<h4 class="section-sub-title">其他附件</h4>
|
||||
<el-upload
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:on-change="(file: UploadFile, fileList: UploadFile[]) => handleAttachmentChange(fileList)"
|
||||
:on-remove="() => handleAttachmentRemove()"
|
||||
accept="image/*,.pdf,.doc,.docx"
|
||||
multiple
|
||||
>
|
||||
<el-button class="btn-ghost">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传其他附件
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<p class="form-label-tip">支持图片、PDF、Word格式。</p>
|
||||
<div v-if="attachmentPreviewUrls.length" class="attachment-preview-list">
|
||||
<span v-for="(url, idx) in attachmentPreviewUrls" :key="idx" class="attachment-preview-item">
|
||||
{{ extractFileName(url) }}
|
||||
<el-button type="danger" link size="small" @click="removeAttachmentFile(idx)">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Delete, Close, Check, Upload } from '@element-plus/icons-vue'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
import {
|
||||
fetchCreateResearchDonation,
|
||||
fetchUpdateResearchDonation,
|
||||
fetchResearchDonationDetail
|
||||
} from '@/api/research-donation'
|
||||
|
||||
defineOptions({ name: 'DonationFormDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editId?: string | null
|
||||
listRow?: Record<string, unknown> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
/* ====== 双向绑定 visible ====== */
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!props.editId)
|
||||
|
||||
/* ====== 状态 ====== */
|
||||
const activeTab = ref('basic')
|
||||
const submitLoading = ref(false)
|
||||
const basicFormRef = ref()
|
||||
const partyFormRef = ref()
|
||||
const collectionFormRef = ref()
|
||||
|
||||
/* ====== 表单数据 ====== */
|
||||
function createEmptySample() {
|
||||
return {
|
||||
sampleTypeId: '',
|
||||
sampleCode: '',
|
||||
collectAmount: 0,
|
||||
unitId: '',
|
||||
storageYears: 0,
|
||||
transportType: '',
|
||||
expressCompanyId: '',
|
||||
waybillNo: '',
|
||||
expressFee: 0,
|
||||
carrierName: '',
|
||||
carrierPhone: ''
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyFormData() {
|
||||
return {
|
||||
donationNo: '',
|
||||
donationDate: '',
|
||||
sourceType: 'direct',
|
||||
remark: '',
|
||||
partyCustomer: {
|
||||
motherName: '',
|
||||
motherPhone: '',
|
||||
motherIdCard: '',
|
||||
motherBirthYm: '',
|
||||
motherEthnic: '',
|
||||
motherAddress: '',
|
||||
fatherName: '',
|
||||
fatherPhone: '',
|
||||
fatherIdCard: '',
|
||||
newbornName: '',
|
||||
newbornIdCard: '',
|
||||
newbornDeliveryTime: '',
|
||||
newbornSex: '',
|
||||
deliveryMode: '',
|
||||
birthOrder: 1,
|
||||
newbornWeightGram: 0,
|
||||
apgar1min: 0,
|
||||
apgar5min: 0,
|
||||
collectorUserId: ''
|
||||
},
|
||||
collectionInfo: {
|
||||
regionId: '',
|
||||
hospitalId: '',
|
||||
medicalStaff: '',
|
||||
salesUserId: '',
|
||||
sampleCollectTime: '',
|
||||
maternalBloodCollectTime: ''
|
||||
},
|
||||
samples: [] as Array<Record<string, unknown>>,
|
||||
consentFormUrls: '',
|
||||
attachmentUrls: ''
|
||||
}
|
||||
}
|
||||
|
||||
const formData = reactive(createEmptyFormData())
|
||||
const consentPreviewUrls = ref<string[]>([])
|
||||
const attachmentPreviewUrls = ref<string[]>([])
|
||||
|
||||
/* ====== 基本信息校验规则 ====== */
|
||||
const basicRules = {
|
||||
donationNo: [{ required: true, message: '请输入捐赠编号', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
/* ====== 样本操作 ====== */
|
||||
function addSample() {
|
||||
formData.samples.push(createEmptySample())
|
||||
}
|
||||
|
||||
function removeSample(index: number) {
|
||||
formData.samples.splice(index, 1)
|
||||
}
|
||||
|
||||
/* ====== 附件操作 ====== */
|
||||
function handleConsentChange(_fileList: UploadFile[]) {
|
||||
// TODO: 实际上传到 MinIO 后获取 URL,此处占位
|
||||
}
|
||||
|
||||
function handleConsentRemove() {
|
||||
// TODO: 同步移除对应 URL
|
||||
}
|
||||
|
||||
function removeConsentFile(index: number) {
|
||||
consentPreviewUrls.value.splice(index, 1)
|
||||
formData.consentFormUrls = consentPreviewUrls.value.join(',')
|
||||
}
|
||||
|
||||
function handleAttachmentChange(_fileList: UploadFile[]) {
|
||||
// TODO: 实际上传到 MinIO 后获取 URL,此处占位
|
||||
}
|
||||
|
||||
function handleAttachmentRemove() {
|
||||
// TODO: 同步移除对应 URL
|
||||
}
|
||||
|
||||
function removeAttachmentFile(index: number) {
|
||||
attachmentPreviewUrls.value.splice(index, 1)
|
||||
formData.attachmentUrls = attachmentPreviewUrls.value.join(',')
|
||||
}
|
||||
|
||||
function extractFileName(url: string): string {
|
||||
if (!url) return ''
|
||||
const parts = url.split('/')
|
||||
return parts[parts.length - 1] || url
|
||||
}
|
||||
|
||||
/* ====== 提交 ====== */
|
||||
async function handleSubmit() {
|
||||
// 校验基本信息
|
||||
try {
|
||||
await basicFormRef.value?.validate()
|
||||
} catch {
|
||||
activeTab.value = 'basic'
|
||||
return
|
||||
}
|
||||
|
||||
// 校验知情同意书
|
||||
if (!formData.consentFormUrls) {
|
||||
ElMessage.warning('请上传知情同意书')
|
||||
activeTab.value = 'attachment'
|
||||
return
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...toRaw(formData) }
|
||||
if (isEdit.value && props.editId) {
|
||||
await fetchUpdateResearchDonation(props.editId, payload)
|
||||
} else {
|
||||
await fetchCreateResearchDonation(payload)
|
||||
}
|
||||
emit('saved')
|
||||
visible.value = false
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== 弹窗打开时:填充或重置 ====== */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (!open) return
|
||||
activeTab.value = 'basic'
|
||||
|
||||
if (props.editId) {
|
||||
// 编辑模式:从详情接口拉取完整数据
|
||||
try {
|
||||
const res = await fetchResearchDonationDetail(props.editId)
|
||||
const d = res.donation || {}
|
||||
const p = res.party || null
|
||||
const c = res.collection || null
|
||||
const s = res.samples || []
|
||||
|
||||
formData.donationNo = String(d.donationNo || '')
|
||||
formData.donationDate = String(d.donationDate || '')
|
||||
formData.sourceType = String(d.sourceType || 'direct')
|
||||
formData.remark = String(d.remark || '')
|
||||
|
||||
if (p) {
|
||||
Object.keys(p).forEach((key) => {
|
||||
if (key in formData.partyCustomer) {
|
||||
;(formData.partyCustomer as Record<string, unknown>)[key] = p[key] ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (c) {
|
||||
Object.keys(c).forEach((key) => {
|
||||
if (key in formData.collectionInfo) {
|
||||
;(formData.collectionInfo as Record<string, unknown>)[key] = c[key] ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
formData.samples = s.length
|
||||
? s.map((item) => ({
|
||||
...createEmptySample(),
|
||||
...item
|
||||
}))
|
||||
: []
|
||||
|
||||
const consentStr = String(d.consentFormUrls || '')
|
||||
formData.consentFormUrls = consentStr
|
||||
consentPreviewUrls.value = consentStr ? consentStr.split(',').filter(Boolean) : []
|
||||
|
||||
const attachStr = String(d.attachmentUrls || '')
|
||||
formData.attachmentUrls = attachStr
|
||||
attachmentPreviewUrls.value = attachStr ? attachStr.split(',').filter(Boolean) : []
|
||||
} catch {
|
||||
ElMessage.error('获取详情失败')
|
||||
}
|
||||
} else {
|
||||
// 新增模式:重置
|
||||
Object.assign(formData, createEmptyFormData())
|
||||
consentPreviewUrls.value = []
|
||||
attachmentPreviewUrls.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.donation-form-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-sub-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 20px 0 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sample-toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sample-block {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--el-bg-color);
|
||||
|
||||
.sample-block-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sample-block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.attachment-preview-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.attachment-preview-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<!-- 捐赠协议单 — 状态变更弹窗(生效 / 过期 / 作废 / 转细胞存储) -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<p class="status-change-hint">
|
||||
捐赠编号:<strong>{{ listRow?.donationNo || '—' }}</strong>
|
||||
</p>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="biz-dialog-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="变更操作" prop="operationType">
|
||||
<el-radio-group v-model="form.operationType" @change="handleOperationChange">
|
||||
<el-radio
|
||||
v-for="op in availableOperations"
|
||||
:key="op.value"
|
||||
:value="op.value"
|
||||
>
|
||||
{{ op.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="form.operationType === 'zckc'"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
class="convert-alert"
|
||||
>
|
||||
转为细胞存储后将创建新的细胞存储协议,需补填缴费信息。
|
||||
</el-alert>
|
||||
|
||||
<el-form-item :label="timeLabel" prop="changeTime">
|
||||
<el-date-picker
|
||||
v-model="form.changeTime"
|
||||
type="datetime"
|
||||
placeholder="请选择时间"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input
|
||||
v-model="form.changeDesc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可选"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { fetchDonationStatusChange } from '@/api/research-donation'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
type OperationType = 'activate' | 'expire' | 'void' | 'zckc'
|
||||
|
||||
defineOptions({ name: 'DonationStatusChangeDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
donationId: string | null
|
||||
listRow?: Record<string, unknown> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const OPERATION_CONFIG: Record<
|
||||
OperationType,
|
||||
{
|
||||
label: string
|
||||
timeLabel: string
|
||||
targetStatus: string
|
||||
}
|
||||
> = {
|
||||
activate: {
|
||||
label: '生效',
|
||||
timeLabel: '生效时间',
|
||||
targetStatus: 'sxz'
|
||||
},
|
||||
expire: {
|
||||
label: '过期',
|
||||
timeLabel: '过期时间',
|
||||
targetStatus: 'ygq'
|
||||
},
|
||||
void: {
|
||||
label: '作废',
|
||||
timeLabel: '作废时间',
|
||||
targetStatus: 'yzf'
|
||||
},
|
||||
zckc: {
|
||||
label: '转细胞存储',
|
||||
timeLabel: '转换时间',
|
||||
targetStatus: 'zckc'
|
||||
}
|
||||
}
|
||||
|
||||
const currentStatus = computed(() => String(props.listRow?.donationStatus || ''))
|
||||
|
||||
const availableOperations = computed(() => {
|
||||
const ops: Array<{ value: OperationType; label: string }> = []
|
||||
const s = currentStatus.value
|
||||
|
||||
if (s === 'dsx') {
|
||||
ops.push({ value: 'activate', label: '生效' })
|
||||
ops.push({ value: 'void', label: '作废' })
|
||||
} else if (s === 'sxz') {
|
||||
ops.push({ value: 'expire', label: '过期' })
|
||||
ops.push({ value: 'void', label: '作废' })
|
||||
ops.push({ value: 'zckc', label: '转细胞存储' })
|
||||
}
|
||||
|
||||
return ops
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
operationType: '' as OperationType | '',
|
||||
changeTime: '',
|
||||
changeDesc: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
operationType: [{ required: true, message: '请选择变更操作', trigger: 'change' }],
|
||||
changeTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
if (!form.operationType) return null
|
||||
return OPERATION_CONFIG[form.operationType]
|
||||
})
|
||||
|
||||
const timeLabel = computed(() => config.value?.timeLabel || '时间')
|
||||
|
||||
const dialogTitle = computed(() => '捐赠协议单 — 状态变更')
|
||||
|
||||
function handleOperationChange() {
|
||||
form.changeTime = ''
|
||||
form.changeDesc = ''
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.operationType = ''
|
||||
form.changeTime = ''
|
||||
form.changeDesc = ''
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !props.donationId || !form.operationType) return
|
||||
|
||||
const cfg = OPERATION_CONFIG[form.operationType]
|
||||
const payload = {
|
||||
donationStatus: cfg.targetStatus,
|
||||
time: form.changeTime,
|
||||
desc: form.changeDesc.trim() || undefined
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await withFullscreenLoading(
|
||||
async () => {
|
||||
await fetchDonationStatusChange(props.donationId!, payload)
|
||||
emit('update:modelValue', false)
|
||||
emit('saved')
|
||||
},
|
||||
fullscreenLoadingText.save
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-change-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.convert-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,975 @@
|
||||
<!-- 捐赠协议单 — 只读查看弹窗 -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="1600px"
|
||||
class="dialog-form-layout donation-view-dialog"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
style="backdrop-filter: none"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div v-loading="loading" class="donation-view-body">
|
||||
<div class="detail-tabs">
|
||||
<div
|
||||
v-for="tab in formTabs"
|
||||
:key="tab.key"
|
||||
class="detail-tab"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="scrollToSection(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="detail">
|
||||
<!-- KPI 摘要条 -->
|
||||
<div class="kpi-strip">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">捐赠编号</div>
|
||||
<div class="kpi-value">{{ dash(donation?.donationNo) }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">状态</div>
|
||||
<div class="kpi-value">
|
||||
<span class="badge" :class="statusBadgeClass(String(donation?.donationStatus || ''))">
|
||||
{{ donationStatusLabel(String(donation?.donationStatus || '')) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">母亲姓名</div>
|
||||
<div class="kpi-value">{{ dash(party?.motherName) }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">捐赠日期</div>
|
||||
<div class="kpi-value">{{ dash(donation?.donationDate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div id="view-section-basic" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>基本信息</h3></div>
|
||||
<div class="data-grid data-grid--3">
|
||||
<div class="data-item">
|
||||
<div class="data-label">捐赠编号</div>
|
||||
<div class="data-value">{{ dash(donation?.donationNo) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">捐赠日期</div>
|
||||
<div class="data-value">{{ dash(donation?.donationDate) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">状态</div>
|
||||
<div class="data-value">
|
||||
<span class="badge" :class="statusBadgeClass(String(donation?.donationStatus || ''))">
|
||||
{{ donationStatusLabel(String(donation?.donationStatus || '')) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">来源类型</div>
|
||||
<div class="data-value">{{ sourceTypeLabel(String(donation?.sourceType || '')) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">创建时间</div>
|
||||
<div class="data-value">{{ dash(donation?.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 状态变更记录 -->
|
||||
<div v-if="hasAnyStatusChange" class="status-change-section">
|
||||
<div class="status-change-title">状态变更记录</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<template v-if="donation?.effectiveTime">
|
||||
<div class="data-item">
|
||||
<div class="data-label">生效时间</div>
|
||||
<div class="data-value">{{ dash(donation.effectiveTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">生效描述</div>
|
||||
<div class="data-value data-value--muted">{{ dash(donation.effectiveDesc) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="donation?.expireTime">
|
||||
<div class="data-item">
|
||||
<div class="data-label">过期时间</div>
|
||||
<div class="data-value">{{ dash(donation.expireTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">过期描述</div>
|
||||
<div class="data-value data-value--muted">{{ dash(donation.expireDesc) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="donation?.voidTime">
|
||||
<div class="data-item">
|
||||
<div class="data-label">作废时间</div>
|
||||
<div class="data-value">{{ dash(donation.voidTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">作废描述</div>
|
||||
<div class="data-value data-value--muted">{{ dash(donation.voidDesc) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="donation?.convertTime">
|
||||
<div class="data-item">
|
||||
<div class="data-label">转细胞存储时间</div>
|
||||
<div class="data-value">{{ dash(donation.convertTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">转细胞存储描述</div>
|
||||
<div class="data-value data-value--muted">{{ dash(donation.convertDesc) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 备注 -->
|
||||
<div v-if="donation?.remark" class="remark-row">
|
||||
<div class="data-item data-item--full">
|
||||
<div class="data-label">备注</div>
|
||||
<div class="data-value data-value--muted">{{ dash(donation?.remark) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当事人信息 -->
|
||||
<div id="view-section-party" class="section-card section-card--green">
|
||||
<div class="section-title"><h3>当事人信息</h3></div>
|
||||
<div class="client-sub-cards">
|
||||
<!-- 母亲信息 -->
|
||||
<div class="client-sub-card">
|
||||
<div class="sub-card-header"><span class="sub-card-icon">🤱</span><h4>母亲信息</h4></div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">母亲姓名</div>
|
||||
<div class="data-value">{{ dash(party?.motherName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">联系电话</div>
|
||||
<div class="data-value">{{ dash(party?.motherPhone) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">身份证号</div>
|
||||
<div class="data-value">{{ dash(party?.motherIdCard) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">出生年月</div>
|
||||
<div class="data-value">{{ dash(party?.motherBirthYm) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">民族</div>
|
||||
<div class="data-value">{{ dash(party?.motherEthnic) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-card-full-row">
|
||||
<div class="data-label">地址</div>
|
||||
<div class="data-value data-value--muted">{{ dash(party?.motherAddress) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 父亲信息 -->
|
||||
<div class="client-sub-card">
|
||||
<div class="sub-card-header"><span class="sub-card-icon">👨</span><h4>父亲信息</h4></div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">父亲姓名</div>
|
||||
<div class="data-value">{{ dash(party?.fatherName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">联系电话</div>
|
||||
<div class="data-value">{{ dash(party?.fatherPhone) }}</div>
|
||||
</div>
|
||||
<div class="data-item data-item--full">
|
||||
<div class="data-label">身份证号</div>
|
||||
<div class="data-value">{{ dash(party?.fatherIdCard) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 新生儿信息 -->
|
||||
<div class="client-sub-card">
|
||||
<div class="sub-card-header"><span class="sub-card-icon">👶</span><h4>新生儿信息</h4></div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">新生儿姓名</div>
|
||||
<div class="data-value">{{ dash(party?.newbornName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">身份证号</div>
|
||||
<div class="data-value">{{ dash(party?.newbornIdCard) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">出生时间</div>
|
||||
<div class="data-value">{{ dash(party?.newbornDeliveryTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">性别</div>
|
||||
<div class="data-value">{{ newbornSexLabel(String(party?.newbornSex || '')) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">分娩方式</div>
|
||||
<div class="data-value">{{ deliveryModeLabel(String(party?.deliveryMode || '')) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">胎次</div>
|
||||
<div class="data-value">{{ dash(party?.birthOrder) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">出生体重(g)</div>
|
||||
<div class="data-value">{{ dash(party?.newbornWeightGram) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">Apgar 1分钟</div>
|
||||
<div class="data-value">{{ dash(party?.apgar1min) }}</div>
|
||||
</div>
|
||||
<div class="data-item data-item--full">
|
||||
<div class="data-label">Apgar 5分钟</div>
|
||||
<div class="data-value">{{ dash(party?.apgar5min) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 采集信息 -->
|
||||
<div id="view-section-collection" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>采集信息</h3></div>
|
||||
<div class="data-grid data-grid--3">
|
||||
<div class="data-item">
|
||||
<div class="data-label">区域</div>
|
||||
<div class="data-value">{{ dash(collection?.regionName || collection?.regionId) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">医院</div>
|
||||
<div class="data-value">{{ dash(collection?.hospitalName || collection?.hospitalId) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">医护人员</div>
|
||||
<div class="data-value">{{ dash(collection?.medicalStaff) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">业务员</div>
|
||||
<div class="data-value">{{ dash(collection?.salesUserName || collection?.salesUserId) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">样本采集时间</div>
|
||||
<div class="data-value">{{ dash(collection?.sampleCollectTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">母血采集时间</div>
|
||||
<div class="data-value">{{ dash(collection?.maternalBloodCollectTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 样本信息 -->
|
||||
<div id="view-section-sample" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>样本信息</h3></div>
|
||||
<div v-if="samples.length" class="sample-cards">
|
||||
<div
|
||||
v-for="(row, sIdx) in samples"
|
||||
:key="row.id || sIdx"
|
||||
class="sample-card"
|
||||
>
|
||||
<div class="sample-card__header">
|
||||
<div class="sample-card__title">
|
||||
<span class="sample-card__seq">{{ sIdx + 1 }}</span>
|
||||
{{ dash(row.sampleTypeName || row.sampleTypeId) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sample-card__body">
|
||||
<div class="sample-card__section">
|
||||
<div class="sample-card__section-label">样本信息</div>
|
||||
<div class="data-grid data-grid--3">
|
||||
<div class="data-item">
|
||||
<div class="data-label">样本编码</div>
|
||||
<div class="data-value data-value--code">{{ dash(row.sampleCode) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">采集量</div>
|
||||
<div class="data-value">{{ dash(row.collectAmount) }} {{ dash(row.unitName || row.unitId) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">存储年限(年)</div>
|
||||
<div class="data-value">{{ dash(row.storageYears) }}</div>
|
||||
</div>
|
||||
<div v-if="row.remark" class="data-item data-item--full">
|
||||
<div class="data-label">备注</div>
|
||||
<div class="data-value data-value--muted">{{ dash(row.remark) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 运输信息 -->
|
||||
<div v-if="row.transportType" class="sample-card__section sample-card__section--transport">
|
||||
<div class="sample-card__section-label">运输信息</div>
|
||||
<div class="data-grid data-grid--3">
|
||||
<div class="data-item">
|
||||
<div class="data-label">运输方式</div>
|
||||
<div class="data-value">{{ transportTypeLabel(String(row.transportType || '')) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">快递公司</div>
|
||||
<div class="data-value">{{ dash(row.expressCompanyName || row.expressCompanyId) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">运单号</div>
|
||||
<div class="data-value">{{ dash(row.waybillNo) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">运费(元)</div>
|
||||
<div class="data-value">{{ row.expressFee ? `¥${row.expressFee}` : '—' }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">承运人姓名</div>
|
||||
<div class="data-value">{{ dash(row.carrierName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">承运人电话</div>
|
||||
<div class="data-value">{{ dash(row.carrierPhone) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无样本" :image-size="48" />
|
||||
</div>
|
||||
|
||||
<!-- 附件 -->
|
||||
<div id="view-section-attachment" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>附件</h3></div>
|
||||
<!-- 知情同意书 -->
|
||||
<div class="attachment-row" v-if="consentUrls.length">
|
||||
<div class="data-label">知情同意书</div>
|
||||
<div class="attachment-list">
|
||||
<a
|
||||
v-for="(url, idx) in consentUrls"
|
||||
:key="'consent-' + idx"
|
||||
:href="String(url)"
|
||||
target="_blank"
|
||||
class="attachment-link"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
{{ extractFileName(String(url)) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 其他附件 -->
|
||||
<div class="attachment-row" v-if="otherAttachmentUrls.length">
|
||||
<div class="data-label">其他附件</div>
|
||||
<div class="attachment-list">
|
||||
<a
|
||||
v-for="(url, idx) in otherAttachmentUrls"
|
||||
:key="'other-' + idx"
|
||||
:href="String(url)"
|
||||
target="_blank"
|
||||
class="attachment-link"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
{{ extractFileName(String(url)) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!consentUrls.length && !otherAttachmentUrls.length"
|
||||
description="暂无附件"
|
||||
:image-size="48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 审核记录 -->
|
||||
<div v-if="showAuditHistory" id="view-section-audit" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>审核记录</h3></div>
|
||||
<el-table
|
||||
v-loading="auditLoading"
|
||||
class="list-table sub-view-table"
|
||||
:data="auditRecords"
|
||||
stripe
|
||||
empty-text="暂无审核记录"
|
||||
>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="审核时间"
|
||||
min-width="170"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="auditStatus"
|
||||
label="审核状态"
|
||||
width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="tag"
|
||||
:class="String(row.auditStatus) === 'shtg' ? 'success' : 'warning'"
|
||||
>
|
||||
{{ auditStatusLabel(String(row.auditStatus || '')) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="auditRemark"
|
||||
label="审核备注"
|
||||
min-width="200"
|
||||
align="center"
|
||||
header-align="center"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="auditorUserName"
|
||||
label="审核人"
|
||||
min-width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{ dash(row.auditorUserName || row.auditorUserId) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Close, Document } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchResearchDonationDetail,
|
||||
fetchResearchDonationAuditList,
|
||||
type ResearchDonationRow
|
||||
} from '@/api/research-donation'
|
||||
|
||||
defineOptions({ name: 'DonationViewDialog' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
donationId: string | null
|
||||
listRow?: ResearchDonationRow | null
|
||||
showAuditHistory?: boolean
|
||||
}>(),
|
||||
{ showAuditHistory: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
}>()
|
||||
|
||||
const baseFormTabs = [
|
||||
{ key: 'basic', label: '基本信息' },
|
||||
{ key: 'party', label: '当事人信息' },
|
||||
{ key: 'collection', label: '采集信息' },
|
||||
{ key: 'sample', label: '样本信息' },
|
||||
{ key: 'attachment', label: '附件' }
|
||||
] as const
|
||||
|
||||
const formTabs = computed(() => {
|
||||
const tabs: Array<{ key: string; label: string }> = [...baseFormTabs]
|
||||
if (props.showAuditHistory) {
|
||||
tabs.push({ key: 'audit', label: '审核记录' })
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const detail = ref<Record<string, any> | null>(null)
|
||||
const auditRecords = ref<Array<Record<string, unknown>>>([])
|
||||
const activeTab = ref('basic')
|
||||
|
||||
const donation = computed(() => detail.value?.donation as Record<string, any> | undefined)
|
||||
const party = computed(() => detail.value?.party as Record<string, any> | null)
|
||||
const collection = computed(() => detail.value?.collection as Record<string, any> | null)
|
||||
const samples = computed(() => {
|
||||
const rows = (detail.value?.samples as Array<Record<string, any>>) || []
|
||||
return [...rows].sort((a, b) => {
|
||||
const oa = Number(a.orderNum ?? 0)
|
||||
const ob = Number(b.orderNum ?? 0)
|
||||
if (oa !== ob) return oa - ob
|
||||
return String(a.createTime || '').localeCompare(String(b.createTime || ''))
|
||||
})
|
||||
})
|
||||
|
||||
const consentUrls = computed(() => {
|
||||
const str = String(donation.value?.consentFormUrls || '')
|
||||
return str ? str.split(',').filter(Boolean) : []
|
||||
})
|
||||
|
||||
const otherAttachmentUrls = computed(() => {
|
||||
const str = String(donation.value?.attachmentUrls || '')
|
||||
return str ? str.split(',').filter(Boolean) : []
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
const no = donation.value?.donationNo || props.listRow?.donationNo
|
||||
return no ? `查看捐赠协议单 — ${no}` : '查看捐赠协议单'
|
||||
})
|
||||
|
||||
const hasAnyStatusChange = computed(() => {
|
||||
if (!donation.value) return false
|
||||
return !!(
|
||||
donation.value.effectiveTime ||
|
||||
donation.value.expireTime ||
|
||||
donation.value.voidTime ||
|
||||
donation.value.convertTime
|
||||
)
|
||||
})
|
||||
|
||||
function dash(val: unknown) {
|
||||
if (val === null || val === undefined || val === '') return '—'
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function donationStatusLabel(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
dsx: '待生效',
|
||||
sxz: '生效中',
|
||||
ygq: '已过期',
|
||||
yzf: '已作废',
|
||||
zckc: '转细胞存储'
|
||||
}
|
||||
return m[s] || s || '—'
|
||||
}
|
||||
|
||||
function statusBadgeClass(s: string): string {
|
||||
const m: Record<string, string> = {
|
||||
dsx: 'badge--amber',
|
||||
sxz: 'badge--green',
|
||||
ygq: 'badge--gray',
|
||||
yzf: 'badge--red',
|
||||
zckc: 'badge--purple'
|
||||
}
|
||||
return m[s] || 'badge--gray'
|
||||
}
|
||||
|
||||
function sourceTypeLabel(s: string) {
|
||||
if (s === 'direct') return '独立新建'
|
||||
if (s === 'from_storage') return '从存储转来'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
function newbornSexLabel(s: string) {
|
||||
if (s === 'male') return '男'
|
||||
if (s === 'female') return '女'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
function deliveryModeLabel(s: string) {
|
||||
if (s === 'natural') return '顺产'
|
||||
if (s === 'cesarean') return '剖宫产'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
function transportTypeLabel(s: string) {
|
||||
if (s === 'express') return '快递'
|
||||
if (s === 'dedicated') return '专人运送'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
function auditStatusLabel(s: string) {
|
||||
if (s === 'shtg') return '审核通过'
|
||||
if (s === 'shth') return '审核退回'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
function extractFileName(url: string): string {
|
||||
if (!url) return ''
|
||||
const parts = url.split('/')
|
||||
return parts[parts.length - 1] || url
|
||||
}
|
||||
|
||||
function scrollToSection(key: string) {
|
||||
activeTab.value = key
|
||||
const el = document.getElementById(`view-section-${key}`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.donationId) return
|
||||
loading.value = true
|
||||
try {
|
||||
detail.value = await fetchResearchDonationDetail(props.donationId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditRecords() {
|
||||
if (!props.donationId || !props.showAuditHistory) return
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const res = await fetchResearchDonationAuditList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: props.donationId
|
||||
})
|
||||
auditRecords.value = res.list || []
|
||||
} catch {
|
||||
auditRecords.value = []
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClosed() {
|
||||
detail.value = null
|
||||
auditRecords.value = []
|
||||
activeTab.value = 'basic'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open && props.donationId) {
|
||||
loadDetail()
|
||||
if (props.showAuditHistory) loadAuditRecords()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.donation-view-body {
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
padding-bottom: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.detail-tab {
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-strip {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&--white {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--green {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
display: grid;
|
||||
gap: 12px 24px;
|
||||
|
||||
&--2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&--3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.data-item {
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.data-label {
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
|
||||
&--muted {
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
&--code {
|
||||
font-family: 'SF Mono', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.client-sub-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.client-sub-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sub-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-card-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sub-card-full-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&--green {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&--amber {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--gray {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&--red {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
&--purple {
|
||||
background: #f3e8ff;
|
||||
color: #6b21a8;
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
&.success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
}
|
||||
|
||||
.status-change-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.status-change-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.remark-row {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sample-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sample-card {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__seq {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 10px;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--el-color-primary-light-5);
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.attachment-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
background: #fff;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,404 @@
|
||||
<!-- 捐赠协议单 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>捐赠协议单</h1>
|
||||
<p class="page-desc">查看与管理科研捐赠协议单主数据。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">捐赠协议单列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="捐赠编号/备注" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="searchForm.donationStatus"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="待生效" value="dsx" />
|
||||
<el-option label="生效中" value="sxz" />
|
||||
<el-option label="已过期" value="ygq" />
|
||||
<el-option label="已作废" value="yzf" />
|
||||
<el-option label="转细胞存储" value="zckc" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">列表为概要信息,详情见弹窗。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset"
|
||||
><el-icon><RefreshLeft /></el-icon>重置</el-button
|
||||
>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch"
|
||||
><el-icon><Search /></el-icon>搜索</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button type="primary" class="btn-main" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchDelete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="donationNo"
|
||||
label="捐赠编号"
|
||||
min-width="140"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="donationDate"
|
||||
label="捐赠日期"
|
||||
width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="motherName"
|
||||
label="母亲姓名"
|
||||
min-width="100"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="donationStatus"
|
||||
label="状态"
|
||||
width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(String(row.donationStatus || ''))" disable-transitions>
|
||||
{{ donationStatusLabel(String(row.donationStatus || '')) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="sourceType"
|
||||
label="来源类型"
|
||||
width="120"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">{{
|
||||
sourceTypeLabel(String(row.sourceType || ''))
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="createTime"
|
||||
label="创建时间"
|
||||
min-width="180"
|
||||
align="center"
|
||||
header-align="center"
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="320"
|
||||
fixed="right"
|
||||
align="center"
|
||||
header-align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"
|
||||
><el-icon><View /></el-icon>查看</el-button
|
||||
>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"
|
||||
><el-icon><Edit /></el-icon>编辑</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleAudit(row)"
|
||||
><el-icon><CircleCheck /></el-icon>审核</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleStatusChange(row)"
|
||||
><el-icon><Switch /></el-icon>状态变更</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"
|
||||
><el-icon><Delete /></el-icon>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<DonationFormDialog
|
||||
v-model="formDialogVisible"
|
||||
:edit-id="formDialogEditId"
|
||||
:list-row="formDialogRow"
|
||||
@saved="onFormSaved"
|
||||
/>
|
||||
|
||||
<!-- 查看弹窗 -->
|
||||
<DonationViewDialog
|
||||
v-model="viewVisible"
|
||||
:donation-id="viewDonationId"
|
||||
:list-row="viewRow"
|
||||
show-audit-history
|
||||
/>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<DonationAuditDialog
|
||||
v-model="auditVisible"
|
||||
:donation-id="auditDonationId"
|
||||
:list-row="auditRow"
|
||||
@saved="onAuditSaved"
|
||||
/>
|
||||
|
||||
<!-- 状态变更弹窗 -->
|
||||
<DonationStatusChangeDialog
|
||||
v-model="statusChangeVisible"
|
||||
:donation-id="statusChangeDonationId"
|
||||
:list-row="statusChangeRow"
|
||||
@saved="onStatusChangeSaved"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
View,
|
||||
Edit,
|
||||
Delete,
|
||||
Search,
|
||||
RefreshLeft,
|
||||
CircleCheck,
|
||||
Switch
|
||||
} from '@element-plus/icons-vue'
|
||||
import DonationFormDialog from './DonationFormDialog.vue'
|
||||
import DonationViewDialog from './DonationViewDialog.vue'
|
||||
import DonationAuditDialog from './DonationAuditDialog.vue'
|
||||
import DonationStatusChangeDialog from './DonationStatusChangeDialog.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchResearchDonationList,
|
||||
fetchDeleteResearchDonation,
|
||||
type ResearchDonationRow
|
||||
} from '@/api/research-donation'
|
||||
|
||||
defineOptions({ name: 'ResearchDonationList' })
|
||||
|
||||
/* ====== 标签映射 ====== */
|
||||
function donationStatusLabel(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
dsx: '待生效',
|
||||
sxz: '生效中',
|
||||
ygq: '已过期',
|
||||
yzf: '已作废',
|
||||
zckc: '转细胞存储'
|
||||
}
|
||||
return m[s] || s || '-'
|
||||
}
|
||||
|
||||
function statusTagType(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
dsx: 'info',
|
||||
sxz: 'success',
|
||||
ygq: 'warning',
|
||||
yzf: 'danger',
|
||||
zckc: 'primary'
|
||||
}
|
||||
return m[s] || 'info'
|
||||
}
|
||||
|
||||
function sourceTypeLabel(s: string) {
|
||||
if (s === 'direct') return '独立新建'
|
||||
if (s === 'from_storage') return '从存储转来'
|
||||
return s || '-'
|
||||
}
|
||||
|
||||
/* ====== 列表 ====== */
|
||||
const searchForm = reactive<{ keyword: string; donationStatus: string }>({
|
||||
keyword: '',
|
||||
donationStatus: ''
|
||||
})
|
||||
const tableData = ref<ResearchDonationRow[]>([])
|
||||
const selectedRows = ref<ResearchDonationRow[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const handleSelectionChange = (rows: ResearchDonationRow[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchResearchDonationList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
donationStatus: searchForm.donationStatus || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.donationStatus = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 查看弹窗 ====== */
|
||||
const viewVisible = ref(false)
|
||||
const viewDonationId = ref<string | null>(null)
|
||||
const viewRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleView(row: ResearchDonationRow) {
|
||||
viewDonationId.value = String(row.id)
|
||||
viewRow.value = row
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
/* ====== 新增/编辑弹窗 ====== */
|
||||
const formDialogVisible = ref(false)
|
||||
const formDialogEditId = ref<string | null>(null)
|
||||
const formDialogRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleAdd() {
|
||||
formDialogEditId.value = null
|
||||
formDialogRow.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(row: ResearchDonationRow) {
|
||||
formDialogEditId.value = row.id as string
|
||||
formDialogRow.value = row
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onFormSaved() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 审核弹窗 ====== */
|
||||
const auditVisible = ref(false)
|
||||
const auditDonationId = ref<string | null>(null)
|
||||
const auditRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleAudit(row: ResearchDonationRow) {
|
||||
auditDonationId.value = String(row.id)
|
||||
auditRow.value = row
|
||||
auditVisible.value = true
|
||||
}
|
||||
|
||||
function onAuditSaved() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 状态变更弹窗 ====== */
|
||||
const statusChangeVisible = ref(false)
|
||||
const statusChangeDonationId = ref<string | null>(null)
|
||||
const statusChangeRow = ref<ResearchDonationRow | null>(null)
|
||||
|
||||
function handleStatusChange(row: ResearchDonationRow) {
|
||||
statusChangeDonationId.value = String(row.id)
|
||||
statusChangeRow.value = row
|
||||
statusChangeVisible.value = true
|
||||
}
|
||||
|
||||
function onStatusChangeSaved() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 删除 ====== */
|
||||
function handleDelete(row: ResearchDonationRow) {
|
||||
ElMessageBox.confirm(`确认删除捐赠编号「${row.donationNo}」?`, '删除确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
.then(async () => {
|
||||
await fetchDeleteResearchDonation(String(row.id))
|
||||
loadData()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!selectedRows.value.length) return
|
||||
await ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录?`, '批量删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
const rows = [...selectedRows.value]
|
||||
await withFullscreenLoading(async () => {
|
||||
for (const row of rows) {
|
||||
await fetchDeleteResearchDonation(String(row.id))
|
||||
}
|
||||
ElMessage.success('批量删除成功')
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}, fullscreenLoadingText.batchDelete)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
Reference in new issue
Block a user