diff --git a/backend/app.pid b/backend/app.pid index 66f744d1..7217e966 100644 --- a/backend/app.pid +++ b/backend/app.pid @@ -1 +1 @@ -55329 +38796 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 8d56c540..c8bfd798 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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", ] diff --git a/backend/app/api/pilot_service.py b/backend/app/api/pilot_service.py new file mode 100644 index 00000000..df7b4836 --- /dev/null +++ b/backend/app/api/pilot_service.py @@ -0,0 +1,1691 @@ +"""中试服务管理 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 +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 + + +# --------------------------------------------------------------------------- +# 1. 细胞产品类型 +# --------------------------------------------------------------------------- + + +class CellProductTypeCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + product_type_code: str = Field(..., alias="productTypeCode") + product_type_name: str = Field(..., alias="productTypeName") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class CellProductTypeUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + product_type_code: Optional[str] = Field(None, alias="productTypeCode") + product_type_name: Optional[str] = Field(None, alias="productTypeName") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/cell-product-types") +async def list_cell_product_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_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = ps_svc.list_cell_product_types( + db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no + ) + tp = ps_svc.total_pages(total, pageSize) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp} + ) + + +@router.post("/cell-product-types") +async def create_cell_product_type( + body: CellProductTypeCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_cell_product_type( + db, + product_type_code=body.product_type_code, + product_type_name=body.product_type_name, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_cell_product_type(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/cell-product-types/{row_id}") +async def update_cell_product_type( + row_id: str, + body: CellProductTypeUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_cell_product_type( + db, + row_id, + product_type_code=payload.get("product_type_code"), + product_type_name=payload.get("product_type_name"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_cell_product_type(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/cell-product-types/{row_id}") +async def delete_cell_product_type( + row_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_cell_product_type(db, row_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --------------------------------------------------------------------------- +# 2. 细胞产品规格 +# --------------------------------------------------------------------------- + + +class CellProductSpecCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + spec_code: str = Field(..., alias="specCode") + spec_name: str = Field(..., alias="specName") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class CellProductSpecUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + spec_code: Optional[str] = Field(None, alias="specCode") + spec_name: Optional[str] = Field(None, alias="specName") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/cell-product-specs") +async def list_cell_product_specs( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = ps_svc.list_cell_product_specs( + db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no + ) + tp = ps_svc.total_pages(total, pageSize) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp} + ) + + +@router.post("/cell-product-specs") +async def create_cell_product_spec( + body: CellProductSpecCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_cell_product_spec( + db, + spec_code=body.spec_code, + spec_name=body.spec_name, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_cell_product_spec(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/cell-product-specs/{row_id}") +async def update_cell_product_spec( + row_id: str, + body: CellProductSpecUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_cell_product_spec( + db, + row_id, + spec_code=payload.get("spec_code"), + spec_name=payload.get("spec_name"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_cell_product_spec(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/cell-product-specs/{row_id}") +async def delete_cell_product_spec( + row_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_cell_product_spec(db, row_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --------------------------------------------------------------------------- +# 3. 细胞产品价格 +# --------------------------------------------------------------------------- + + +class CellProductPriceCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + project_name: str = Field(..., alias="projectName") + project_code: str = Field(..., alias="projectCode") + cell_type_code: Optional[str] = Field(None, alias="cellTypeCode") + cell_type_name: Optional[str] = Field(None, alias="cellTypeName") + spec_text: Optional[str] = Field(None, alias="specText") + spec_id: Optional[str] = Field(None, alias="specId") + cell_product_type_id: Optional[str] = Field(None, alias="cellProductTypeId") + unit_price: Optional[float] = Field(None, alias="unitPrice") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class CellProductPriceUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + project_name: Optional[str] = Field(None, alias="projectName") + project_code: Optional[str] = Field(None, alias="projectCode") + cell_type_code: Optional[str] = Field(None, alias="cellTypeCode") + cell_type_name: Optional[str] = Field(None, alias="cellTypeName") + spec_text: Optional[str] = Field(None, alias="specText") + spec_id: Optional[str] = Field(None, alias="specId") + cell_product_type_id: Optional[str] = Field(None, alias="cellProductTypeId") + unit_price: Optional[float] = Field(None, alias="unitPrice") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/cell-product-prices") +async def list_cell_product_prices( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + cell_product_type_id: Optional[str] = Query(None, alias="cellProductTypeId"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = ps_svc.list_cell_product_prices( + db, + page=page, + page_size=pageSize, + keyword=keyword, + tenant_no=tenant_no, + cell_product_type_id=cell_product_type_id, + ) + tp = ps_svc.total_pages(total, pageSize) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp} + ) + + +@router.post("/cell-product-prices") +async def create_cell_product_price( + body: CellProductPriceCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_cell_product_price( + db, + project_name=body.project_name, + project_code=body.project_code, + cell_type_code=body.cell_type_code or "", + cell_type_name=body.cell_type_name or "", + spec_text=body.spec_text or "", + spec_id=body.spec_id or "", + cell_product_type_id=body.cell_product_type_id or "", + unit_price=body.unit_price, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_cell_product_price(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/cell-product-prices/{row_id}") +async def update_cell_product_price( + row_id: str, + body: CellProductPriceUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_cell_product_price( + db, + row_id, + project_name=payload.get("project_name"), + project_code=payload.get("project_code"), + cell_type_code=payload.get("cell_type_code"), + cell_type_name=payload.get("cell_type_name"), + spec_text=payload.get("spec_text"), + spec_id=payload.get("spec_id"), + cell_product_type_id=payload.get("cell_product_type_id"), + unit_price=payload.get("unit_price"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_cell_product_price(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/cell-product-prices/{row_id}") +async def delete_cell_product_price( + row_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_cell_product_price(db, row_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --------------------------------------------------------------------------- +# 4. 项目价格体系(主表 + 明细子表) +# --------------------------------------------------------------------------- + + +class ProjectSystemCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + system_name: str = Field(..., alias="systemName") + system_code: str = Field(..., alias="systemCode") + status: Optional[str] = Field("1", alias="status") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class ProjectSystemUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + system_name: Optional[str] = Field(None, alias="systemName") + system_code: Optional[str] = Field(None, alias="systemCode") + status: Optional[str] = Field(None, alias="status") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/project-price-systems") +async def list_project_systems( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = ps_svc.list_project_systems( + db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no + ) + tp = ps_svc.total_pages(total, pageSize) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp} + ) + + +@router.post("/project-price-systems") +async def create_project_system( + body: ProjectSystemCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_project_system( + db, + system_name=body.system_name, + system_code=body.system_code, + status=body.status, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_project_system(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/project-price-systems/{row_id}") +async def update_project_system( + row_id: str, + body: ProjectSystemUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_project_system( + db, + row_id, + system_name=payload.get("system_name"), + system_code=payload.get("system_code"), + status=payload.get("status"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_project_system(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/project-price-systems/{row_id}") +async def delete_project_system( + row_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_project_system(db, row_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 项目价格体系明细 --- + + +class ProjectSystemItemCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + source_price_id: Optional[str] = Field(None, alias="sourcePriceId") + project_name: str = Field(..., alias="projectName") + project_code: str = Field(..., alias="projectCode") + cell_type_code: Optional[str] = Field(None, alias="cellTypeCode") + cell_type_name: Optional[str] = Field(None, alias="cellTypeName") + spec_text: Optional[str] = Field(None, alias="specText") + spec_id: Optional[str] = Field(None, alias="specId") + cell_product_type_id: Optional[str] = Field(None, alias="cellProductTypeId") + unit_price: Optional[float] = Field(None, alias="unitPrice") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class ProjectSystemItemUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + source_price_id: Optional[str] = Field(None, alias="sourcePriceId") + project_name: Optional[str] = Field(None, alias="projectName") + project_code: Optional[str] = Field(None, alias="projectCode") + cell_type_code: Optional[str] = Field(None, alias="cellTypeCode") + cell_type_name: Optional[str] = Field(None, alias="cellTypeName") + spec_text: Optional[str] = Field(None, alias="specText") + spec_id: Optional[str] = Field(None, alias="specId") + cell_product_type_id: Optional[str] = Field(None, alias="cellProductTypeId") + unit_price: Optional[float] = Field(None, alias="unitPrice") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/project-price-systems/{system_id}/items") +async def list_project_system_items( + system_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_project_system_items(db, system_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/project-price-systems/{system_id}/items") +async def create_project_system_item( + system_id: str, + body: ProjectSystemItemCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_project_system_item( + db, + project_system_id=system_id, + source_price_id=body.source_price_id or "", + project_name=body.project_name, + project_code=body.project_code, + cell_type_code=body.cell_type_code or "", + cell_type_name=body.cell_type_name or "", + spec_text=body.spec_text or "", + spec_id=body.spec_id or "", + cell_product_type_id=body.cell_product_type_id or "", + unit_price=body.unit_price, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_project_system_item(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/project-system-items/{item_id}") +async def update_project_system_item( + item_id: str, + body: ProjectSystemItemUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_project_system_item( + db, + item_id, + source_price_id=payload.get("source_price_id"), + project_name=payload.get("project_name"), + project_code=payload.get("project_code"), + cell_type_code=payload.get("cell_type_code"), + cell_type_name=payload.get("cell_type_name"), + spec_text=payload.get("spec_text"), + spec_id=payload.get("spec_id"), + cell_product_type_id=payload.get("cell_product_type_id"), + unit_price=payload.get("unit_price"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_project_system_item(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/project-system-items/{item_id}") +async def delete_project_system_item( + item_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_project_system_item(db, item_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --------------------------------------------------------------------------- +# 5. 渠道信息(主表 + 开票/邮寄地址/协议/协议产品价格子表) +# --------------------------------------------------------------------------- + + +class ChannelCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + channel_name: str = Field(..., alias="channelName") + credit_code: Optional[str] = Field(None, alias="creditCode") + channel_type: Optional[str] = Field(None, alias="channelType") + contact_name: Optional[str] = Field(None, alias="contactName") + contact_phone: Optional[str] = Field(None, alias="contactPhone") + contact_email: Optional[str] = Field(None, alias="contactEmail") + protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class ChannelUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + channel_name: Optional[str] = Field(None, alias="channelName") + credit_code: Optional[str] = Field(None, alias="creditCode") + channel_type: Optional[str] = Field(None, alias="channelType") + contact_name: Optional[str] = Field(None, alias="contactName") + contact_phone: Optional[str] = Field(None, alias="contactPhone") + contact_email: Optional[str] = Field(None, alias="contactEmail") + protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/channels") +async def list_channels( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = ps_svc.list_channels( + db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no + ) + tp = ps_svc.total_pages(total, pageSize) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp} + ) + + +@router.post("/channels") +async def create_channel( + body: ChannelCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_channel( + db, + channel_name=body.channel_name, + credit_code=body.credit_code, + channel_type=body.channel_type, + contact_name=body.contact_name, + contact_phone=body.contact_phone, + contact_email=body.contact_email, + protocol_attachments=body.protocol_attachments, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_channel(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/channels/{row_id}") +async def update_channel( + row_id: str, + body: ChannelUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_channel( + db, + row_id, + channel_name=payload.get("channel_name"), + credit_code=payload.get("credit_code"), + channel_type=payload.get("channel_type"), + contact_name=payload.get("contact_name"), + contact_phone=payload.get("contact_phone"), + contact_email=payload.get("contact_email"), + protocol_attachments=payload.get("protocol_attachments"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_channel(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/channels/{row_id}") +async def delete_channel( + row_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_channel(db, row_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 渠道开票信息 --- + + +class ChannelInvoiceCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + company_name: str = Field(..., alias="companyName") + tax_no: Optional[str] = Field(None, alias="taxNo") + company_address: Optional[str] = Field(None, alias="companyAddress") + company_phone: Optional[str] = Field(None, alias="companyPhone") + bank_name: Optional[str] = Field(None, alias="bankName") + bank_account: Optional[str] = Field(None, alias="bankAccount") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class ChannelInvoiceUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + company_name: Optional[str] = Field(None, alias="companyName") + tax_no: Optional[str] = Field(None, alias="taxNo") + company_address: Optional[str] = Field(None, alias="companyAddress") + company_phone: Optional[str] = Field(None, alias="companyPhone") + bank_name: Optional[str] = Field(None, alias="bankName") + bank_account: Optional[str] = Field(None, alias="bankAccount") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/channels/{channel_id}/invoices") +async def list_channel_invoices( + channel_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_channel_invoices(db, channel_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/channels/{channel_id}/invoices") +async def create_channel_invoice( + channel_id: str, + body: ChannelInvoiceCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_channel_invoice( + db, + channel_id=channel_id, + company_name=body.company_name, + tax_no=body.tax_no, + company_address=body.company_address, + company_phone=body.company_phone, + bank_name=body.bank_name, + bank_account=body.bank_account, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_channel_invoice(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/channel-invoices/{invoice_id}") +async def update_channel_invoice( + invoice_id: str, + body: ChannelInvoiceUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_channel_invoice( + db, + invoice_id, + company_name=payload.get("company_name"), + tax_no=payload.get("tax_no"), + company_address=payload.get("company_address"), + company_phone=payload.get("company_phone"), + bank_name=payload.get("bank_name"), + bank_account=payload.get("bank_account"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_channel_invoice(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/channel-invoices/{invoice_id}") +async def delete_channel_invoice( + invoice_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_channel_invoice(db, invoice_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 渠道邮寄地址 --- + + +class ChannelMailAddressCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + mail_address: str = Field(..., alias="mailAddress") + mail_contact: Optional[str] = Field(None, alias="mailContact") + mail_phone: Optional[str] = Field(None, alias="mailPhone") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class ChannelMailAddressUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + mail_address: Optional[str] = Field(None, alias="mailAddress") + mail_contact: Optional[str] = Field(None, alias="mailContact") + mail_phone: Optional[str] = Field(None, alias="mailPhone") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/channels/{channel_id}/mail-addresses") +async def list_channel_mail_addresses( + channel_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_channel_mail_addresses(db, channel_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/channels/{channel_id}/mail-addresses") +async def create_channel_mail_address( + channel_id: str, + body: ChannelMailAddressCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_channel_mail_address( + db, + channel_id=channel_id, + mail_address=body.mail_address, + mail_contact=body.mail_contact, + mail_phone=body.mail_phone, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_channel_mail_address(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/channel-mail-addresses/{address_id}") +async def update_channel_mail_address( + address_id: str, + body: ChannelMailAddressUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_channel_mail_address( + db, + address_id, + mail_address=payload.get("mail_address"), + mail_contact=payload.get("mail_contact"), + mail_phone=payload.get("mail_phone"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_channel_mail_address(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/channel-mail-addresses/{address_id}") +async def delete_channel_mail_address( + address_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_channel_mail_address(db, address_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 渠道协议 --- + + +class ChannelAgreementCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + agreement_type: str = Field(..., alias="agreementType") + business_type: Optional[str] = Field(None, alias="businessType") + settle_mode: Optional[str] = Field(None, alias="settleMode") + credit_limit_amount: Optional[float] = Field(None, alias="creditLimitAmount") + effective_date: Optional[str] = Field(None, alias="effectiveDate") + expire_date: Optional[str] = Field(None, alias="expireDate") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class ChannelAgreementUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + agreement_type: Optional[str] = Field(None, alias="agreementType") + business_type: Optional[str] = Field(None, alias="businessType") + settle_mode: Optional[str] = Field(None, alias="settleMode") + credit_limit_amount: Optional[float] = Field(None, alias="creditLimitAmount") + effective_date: Optional[str] = Field(None, alias="effectiveDate") + expire_date: Optional[str] = Field(None, alias="expireDate") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/channels/{channel_id}/agreements") +async def list_channel_agreements( + channel_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_channel_agreements(db, channel_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/channels/{channel_id}/agreements") +async def create_channel_agreement( + channel_id: str, + body: ChannelAgreementCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_channel_agreement( + db, + channel_id=channel_id, + agreement_type=body.agreement_type, + business_type=body.business_type, + settle_mode=body.settle_mode, + credit_limit_amount=body.credit_limit_amount, + effective_date=body.effective_date, + expire_date=body.expire_date, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_channel_agreement(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/channel-agreements/{agreement_id}") +async def update_channel_agreement( + agreement_id: str, + body: ChannelAgreementUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_channel_agreement( + db, + agreement_id, + agreement_type=payload.get("agreement_type"), + business_type=payload.get("business_type"), + settle_mode=payload.get("settle_mode"), + credit_limit_amount=payload.get("credit_limit_amount"), + effective_date=payload.get("effective_date"), + expire_date=payload.get("expire_date"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_channel_agreement(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/channel-agreements/{agreement_id}") +async def delete_channel_agreement( + agreement_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_channel_agreement(db, agreement_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/channel-agreements/{agreement_id}/activate") +async def activate_channel_agreement( + agreement_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.activate_channel_agreement( + db, + agreement_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_channel_agreement(obj), message="生效成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/channel-agreements/{agreement_id}/void") +async def void_channel_agreement( + agreement_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.void_channel_agreement( + db, + agreement_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_channel_agreement(obj), message="作废成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 渠道协议产品价格 --- + + +class ChannelAgreementPriceCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + project_name: Optional[str] = Field(None, alias="projectName") + project_code: Optional[str] = Field(None, alias="projectCode") + cell_type_code: Optional[str] = Field(None, alias="cellTypeCode") + cell_type_name: Optional[str] = Field(None, alias="cellTypeName") + spec_text: Optional[str] = Field(None, alias="specText") + original_price: Optional[float] = Field(None, alias="originalPrice") + agreement_price: Optional[float] = Field(None, alias="agreementPrice") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class ChannelAgreementPriceUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + project_name: Optional[str] = Field(None, alias="projectName") + project_code: Optional[str] = Field(None, alias="projectCode") + cell_type_code: Optional[str] = Field(None, alias="cellTypeCode") + cell_type_name: Optional[str] = Field(None, alias="cellTypeName") + spec_text: Optional[str] = Field(None, alias="specText") + original_price: Optional[float] = Field(None, alias="originalPrice") + agreement_price: Optional[float] = Field(None, alias="agreementPrice") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/channel-agreements/{agreement_id}/product-prices") +async def list_channel_agreement_prices( + agreement_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_channel_agreement_prices(db, agreement_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/channel-agreements/{agreement_id}/product-prices") +async def create_channel_agreement_price( + agreement_id: str, + body: ChannelAgreementPriceCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_channel_agreement_price( + db, + channel_agreement_id=agreement_id, + project_name=body.project_name, + project_code=body.project_code, + cell_type_code=body.cell_type_code, + cell_type_name=body.cell_type_name, + spec_text=body.spec_text, + original_price=body.original_price, + agreement_price=body.agreement_price, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_channel_agreement_price(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/channel-agreement-product-prices/{price_id}") +async def update_channel_agreement_price( + price_id: str, + body: ChannelAgreementPriceUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_channel_agreement_price( + db, + price_id, + project_name=payload.get("project_name"), + project_code=payload.get("project_code"), + cell_type_code=payload.get("cell_type_code"), + cell_type_name=payload.get("cell_type_name"), + spec_text=payload.get("spec_text"), + original_price=payload.get("original_price"), + agreement_price=payload.get("agreement_price"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_channel_agreement_price(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/channel-agreement-product-prices/{price_id}") +async def delete_channel_agreement_price( + price_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_channel_agreement_price(db, price_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --------------------------------------------------------------------------- +# 6. 体检机构(主表 + 开票信息/项目及价格子表) +# --------------------------------------------------------------------------- + + +class CheckupInstitutionCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + institution_name: str = Field(..., alias="institutionName") + institution_address: Optional[str] = Field(None, alias="institutionAddress") + contact_name: Optional[str] = Field(None, alias="contactName") + contact_phone: Optional[str] = Field(None, alias="contactPhone") + status: Optional[str] = Field("1", alias="status") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class CheckupInstitutionUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + institution_name: Optional[str] = Field(None, alias="institutionName") + institution_address: Optional[str] = Field(None, alias="institutionAddress") + contact_name: Optional[str] = Field(None, alias="contactName") + contact_phone: Optional[str] = Field(None, alias="contactPhone") + status: Optional[str] = Field(None, alias="status") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/checkup-institutions") +async def list_checkup_institutions( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = ps_svc.list_checkup_institutions( + db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no + ) + tp = ps_svc.total_pages(total, pageSize) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp} + ) + + +@router.post("/checkup-institutions") +async def create_checkup_institution( + body: CheckupInstitutionCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_checkup_institution( + db, + institution_name=body.institution_name, + institution_address=body.institution_address, + contact_name=body.contact_name, + contact_phone=body.contact_phone, + status=body.status, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_checkup_institution(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/checkup-institutions/{row_id}") +async def update_checkup_institution( + row_id: str, + body: CheckupInstitutionUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_checkup_institution( + db, + row_id, + institution_name=payload.get("institution_name"), + institution_address=payload.get("institution_address"), + contact_name=payload.get("contact_name"), + contact_phone=payload.get("contact_phone"), + status=payload.get("status"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_checkup_institution(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/checkup-institutions/{row_id}") +async def delete_checkup_institution( + row_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_checkup_institution(db, row_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 体检机构开票信息 --- + + +class CheckupInstitutionInvoiceCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + company_name: str = Field(..., alias="companyName") + tax_no: Optional[str] = Field(None, alias="taxNo") + company_address: Optional[str] = Field(None, alias="companyAddress") + company_phone: Optional[str] = Field(None, alias="companyPhone") + bank_name: Optional[str] = Field(None, alias="bankName") + bank_account: Optional[str] = Field(None, alias="bankAccount") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class CheckupInstitutionInvoiceUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + company_name: Optional[str] = Field(None, alias="companyName") + tax_no: Optional[str] = Field(None, alias="taxNo") + company_address: Optional[str] = Field(None, alias="companyAddress") + company_phone: Optional[str] = Field(None, alias="companyPhone") + bank_name: Optional[str] = Field(None, alias="bankName") + bank_account: Optional[str] = Field(None, alias="bankAccount") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/checkup-institutions/{institution_id}/invoices") +async def list_checkup_institution_invoices( + institution_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_checkup_institution_invoices(db, institution_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/checkup-institutions/{institution_id}/invoices") +async def create_checkup_institution_invoice( + institution_id: str, + body: CheckupInstitutionInvoiceCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_checkup_institution_invoice( + db, + checkup_institution_id=institution_id, + company_name=body.company_name, + tax_no=body.tax_no, + company_address=body.company_address, + company_phone=body.company_phone, + bank_name=body.bank_name, + bank_account=body.bank_account, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_checkup_institution_invoice(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/checkup-institution-invoices/{invoice_id}") +async def update_checkup_institution_invoice( + invoice_id: str, + body: CheckupInstitutionInvoiceUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_checkup_institution_invoice( + db, + invoice_id, + company_name=payload.get("company_name"), + tax_no=payload.get("tax_no"), + company_address=payload.get("company_address"), + company_phone=payload.get("company_phone"), + bank_name=payload.get("bank_name"), + bank_account=payload.get("bank_account"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_checkup_institution_invoice(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/checkup-institution-invoices/{invoice_id}") +async def delete_checkup_institution_invoice( + invoice_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_checkup_institution_invoice(db, invoice_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 体检机构项目及价格 --- + + +class CheckupInstitutionProjectCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + project_name: str = Field(..., alias="projectName") + project_fee: Optional[float] = Field(None, alias="projectFee") + status: Optional[str] = Field("1", alias="status") + order_num: Optional[int] = Field(0, alias="orderNum") + remark: Optional[str] = None + + +class CheckupInstitutionProjectUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + project_name: Optional[str] = Field(None, alias="projectName") + project_fee: Optional[float] = Field(None, alias="projectFee") + status: Optional[str] = Field(None, alias="status") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/checkup-institutions/{institution_id}/projects") +async def list_checkup_institution_projects( + institution_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_checkup_institution_projects(db, institution_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/checkup-institutions/{institution_id}/projects") +async def create_checkup_institution_project( + institution_id: str, + body: CheckupInstitutionProjectCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = ps_svc.create_checkup_institution_project( + db, + checkup_institution_id=institution_id, + project_name=body.project_name, + project_fee=body.project_fee, + status=body.status, + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(ps_svc._row_to_dict_checkup_institution_project(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/checkup-institution-projects/{project_id}") +async def update_checkup_institution_project( + project_id: str, + body: CheckupInstitutionProjectUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = ps_svc.update_checkup_institution_project( + db, + project_id, + project_name=payload.get("project_name"), + project_fee=payload.get("project_fee"), + status=payload.get("status"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(ps_svc._row_to_dict_checkup_institution_project(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/checkup-institution-projects/{project_id}") +async def delete_checkup_institution_project( + project_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_checkup_institution_project(db, project_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --- 体检机构项目套餐 --- + + +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 + + +def _package_items_to_crud(items) -> List[dict]: + out = [] + for x in items: + if isinstance(x, dict): + out.append( + { + "checkup_institution_project_id": x.get("checkup_institution_project_id") + or x.get("checkupInstitutionProjectId"), + "project_name": x.get("project_name") or x.get("projectName"), + "project_fee": x.get("project_fee") if x.get("project_fee") is not None else x.get("projectFee"), + "order_num": x.get("order_num") if x.get("order_num") is not None else x.get("orderNum"), + "remark": x.get("remark"), + } + ) + else: + out.append( + { + "checkup_institution_project_id": x.checkup_institution_project_id, + "project_name": x.project_name, + "project_fee": x.project_fee, + "order_num": x.order_num, + "remark": x.remark, + } + ) + return out + + +@router.get("/checkup-institutions/{institution_id}/packages") +async def list_checkup_institution_packages( + institution_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + items = ps_svc.list_checkup_institution_packages(db, institution_id, tenant_no=tenant_no) + return success_response(items) + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/checkup-institutions/{institution_id}/packages") +async def create_checkup_institution_package( + institution_id: str, + body: CheckupInstitutionPackageCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + detail = ps_svc.create_checkup_institution_package( + db, + checkup_institution_id=institution_id, + package_name=body.package_name, + package_code=body.package_code, + status=body.status, + items=_package_items_to_crud(body.items), + tenant_no=tenant_no, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(detail, message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.get("/checkup-institution-packages/{package_id}") +async def get_checkup_institution_package_detail( + package_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + detail = ps_svc.get_checkup_institution_package_detail(db, package_id, tenant_no=tenant_no) + if not detail: + return error_response(404, "记录不存在") + return success_response(detail) + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/checkup-institution-packages/{package_id}") +async def update_checkup_institution_package( + package_id: str, + body: CheckupInstitutionPackageUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload = body.model_dump(exclude_unset=True, by_alias=False) + items_raw = payload.pop("items", None) + items = _package_items_to_crud(items_raw) if items_raw is not None else None + detail = ps_svc.update_checkup_institution_package( + db, + package_id, + package_name=payload.get("package_name"), + package_code=payload.get("package_code"), + status=payload.get("status"), + items=items, + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + tenant_no=tenant_no, + ) + return success_response(detail, message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/checkup-institution-packages/{package_id}") +async def delete_checkup_institution_package( + package_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + ps_svc.soft_delete_checkup_institution_package(db, package_id, tenant_no=tenant_no) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) diff --git a/backend/app/api/production.py b/backend/app/api/production.py new file mode 100644 index 00000000..7a1e088c --- /dev/null +++ b/backend/app/api/production.py @@ -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)) diff --git a/backend/app/api/research_donation.py b/backend/app/api/research_donation.py new file mode 100644 index 00000000..04255c7e --- /dev/null +++ b/backend/app/api/research_donation.py @@ -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)) diff --git a/backend/app/crud/consumable.py b/backend/app/crud/consumable.py new file mode 100644 index 00000000..21111410 --- /dev/null +++ b/backend/app/crud/consumable.py @@ -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))) diff --git a/backend/app/crud/consumable_pack.py b/backend/app/crud/consumable_pack.py new file mode 100644 index 00000000..96b366fd --- /dev/null +++ b/backend/app/crud/consumable_pack.py @@ -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))) diff --git a/backend/app/crud/output_type.py b/backend/app/crud/output_type.py new file mode 100644 index 00000000..41516dd9 --- /dev/null +++ b/backend/app/crud/output_type.py @@ -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))) diff --git a/backend/app/crud/pilot_service.py b/backend/app/crud/pilot_service.py new file mode 100644 index 00000000..01221c5f --- /dev/null +++ b/backend/app/crud/pilot_service.py @@ -0,0 +1,2182 @@ +"""中试服务管理 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, + BizCheckupInstitutionPackage, + BizCheckupInstitutionPackageItem, +) + + +# ──────────────────────────────────────────── +# Entity 1: BizCellProductType 细胞产品类型 +# ──────────────────────────────────────────── + +def _row_to_dict_cell_product_type(row) -> Dict[str, Any]: + return { + "id": row.id, + "productTypeCode": row.product_type_code or "", + "productTypeName": row.product_type_name or "", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_cell_product_types(db, *, page=1, page_size=10, keyword=None, tenant_no=None): + def _filtered(): + q0 = db.query(BizCellProductType).filter(BizCellProductType.del_flag == 0) + if tenant_no: + q0 = q0.filter(BizCellProductType.tenant_no == tenant_no) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q0 = q0.filter( + or_( + BizCellProductType.product_type_code.like(like), + BizCellProductType.product_type_name.like(like), + ) + ) + return q0 + + total = _filtered().order_by(None).with_entities(func.count(BizCellProductType.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = _filtered().order_by(BizCellProductType.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all() + return [_row_to_dict_cell_product_type(x) for x in rows], int(total) + + +def get_cell_product_type(db, type_id, tenant_no=None): + q = db.query(BizCellProductType).filter(and_(BizCellProductType.id == type_id, BizCellProductType.del_flag == 0)) + if tenant_no: + q = q.filter(BizCellProductType.tenant_no == tenant_no) + return q.first() + + +def cell_product_type_code_exists(db, code, tenant_no, exclude_id=None): + q = db.query(BizCellProductType).filter( + and_(BizCellProductType.product_type_code == code, BizCellProductType.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCellProductType.tenant_no == tenant_no) + if exclude_id: + q = q.filter(BizCellProductType.id != exclude_id) + return db.query(q.exists()).scalar() + + +def create_cell_product_type(db, *, product_type_code, product_type_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None): + if cell_product_type_code_exists(db, product_type_code, tenant_no): + raise ValueError(f"产品类型编码 {product_type_code} 已存在") + obj = BizCellProductType( + id=generate_uuid(), + product_type_code=product_type_code, + product_type_name=product_type_name, + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_cell_product_type(db, type_id, *, product_type_code=None, product_type_name=None, order_num=None, remark=None, user_id=None, tenant_no=None): + obj = get_cell_product_type(db, type_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + if product_type_code is not None and product_type_code != obj.product_type_code: + if cell_product_type_code_exists(db, product_type_code, tenant_no, exclude_id=type_id): + raise ValueError(f"产品类型编码 {product_type_code} 已存在") + obj.product_type_code = product_type_code + if product_type_name is not None: + obj.product_type_name = product_type_name + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_cell_product_type(db, type_id, tenant_no=None): + obj = get_cell_product_type(db, type_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 2: BizCellProductSpec 细胞产品规格 +# ──────────────────────────────────────────── + +def _row_to_dict_cell_product_spec(row) -> Dict[str, Any]: + return { + "id": row.id, + "specCode": row.spec_code or "", + "specName": row.spec_name or "", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_cell_product_specs(db, *, page=1, page_size=10, keyword=None, tenant_no=None): + def _filtered(): + q0 = db.query(BizCellProductSpec).filter(BizCellProductSpec.del_flag == 0) + if tenant_no: + q0 = q0.filter(BizCellProductSpec.tenant_no == tenant_no) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q0 = q0.filter( + or_( + BizCellProductSpec.spec_code.like(like), + BizCellProductSpec.spec_name.like(like), + ) + ) + return q0 + + total = _filtered().order_by(None).with_entities(func.count(BizCellProductSpec.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = _filtered().order_by(BizCellProductSpec.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all() + return [_row_to_dict_cell_product_spec(x) for x in rows], int(total) + + +def get_cell_product_spec(db, spec_id, tenant_no=None): + q = db.query(BizCellProductSpec).filter(and_(BizCellProductSpec.id == spec_id, BizCellProductSpec.del_flag == 0)) + if tenant_no: + q = q.filter(BizCellProductSpec.tenant_no == tenant_no) + return q.first() + + +def cell_product_spec_code_exists(db, code, tenant_no, exclude_id=None): + q = db.query(BizCellProductSpec).filter( + and_(BizCellProductSpec.spec_code == code, BizCellProductSpec.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCellProductSpec.tenant_no == tenant_no) + if exclude_id: + q = q.filter(BizCellProductSpec.id != exclude_id) + return db.query(q.exists()).scalar() + + +def create_cell_product_spec(db, *, spec_code, spec_name, tenant_no="zkxw", order_num=None, remark=None, user_id=None): + if cell_product_spec_code_exists(db, spec_code, tenant_no): + raise ValueError(f"规格编码 {spec_code} 已存在") + obj = BizCellProductSpec( + id=generate_uuid(), + spec_code=spec_code, + spec_name=spec_name, + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_cell_product_spec(db, spec_id, *, spec_code=None, spec_name=None, order_num=None, remark=None, user_id=None, tenant_no=None): + obj = get_cell_product_spec(db, spec_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + if spec_code is not None and spec_code != obj.spec_code: + if cell_product_spec_code_exists(db, spec_code, tenant_no, exclude_id=spec_id): + raise ValueError(f"规格编码 {spec_code} 已存在") + obj.spec_code = spec_code + if spec_name is not None: + obj.spec_name = spec_name + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_cell_product_spec(db, spec_id, tenant_no=None): + obj = get_cell_product_spec(db, spec_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 3: BizCellProductPrice 细胞产品价格 +# ──────────────────────────────────────────── + +def _row_to_dict_cell_product_price(row) -> Dict[str, Any]: + return { + "id": row.id, + "projectName": row.project_name or "", + "projectCode": row.project_code or "", + "cellTypeCode": row.cell_type_code or "", + "cellTypeName": row.cell_type_name or "", + "specText": row.spec_text or "", + "specId": row.spec_id or "", + "cellProductTypeId": row.cell_product_type_id or "", + "unitPrice": float(row.unit_price) if row.unit_price is not None else None, + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_cell_product_prices(db, *, page=1, page_size=10, keyword=None, tenant_no=None, cell_product_type_id=None): + def _filtered(): + q0 = db.query(BizCellProductPrice).filter(BizCellProductPrice.del_flag == 0) + if tenant_no: + q0 = q0.filter(BizCellProductPrice.tenant_no == tenant_no) + if cell_product_type_id: + q0 = q0.filter(BizCellProductPrice.cell_product_type_id == cell_product_type_id) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q0 = q0.filter( + or_( + BizCellProductPrice.project_name.like(like), + BizCellProductPrice.project_code.like(like), + BizCellProductPrice.cell_type_name.like(like), + ) + ) + return q0 + + total = _filtered().order_by(None).with_entities(func.count(BizCellProductPrice.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = _filtered().order_by(BizCellProductPrice.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all() + return [_row_to_dict_cell_product_price(x) for x in rows], int(total) + + +def get_cell_product_price(db, price_id, tenant_no=None): + q = db.query(BizCellProductPrice).filter(and_(BizCellProductPrice.id == price_id, BizCellProductPrice.del_flag == 0)) + if tenant_no: + q = q.filter(BizCellProductPrice.tenant_no == tenant_no) + return q.first() + + +def cell_product_price_code_exists(db, code, tenant_no, exclude_id=None): + q = db.query(BizCellProductPrice).filter( + and_(BizCellProductPrice.project_code == code, BizCellProductPrice.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCellProductPrice.tenant_no == tenant_no) + if exclude_id: + q = q.filter(BizCellProductPrice.id != exclude_id) + return db.query(q.exists()).scalar() + + +def create_cell_product_price( + db, + *, + project_name, + project_code, + cell_type_code, + cell_type_name, + spec_text, + spec_id, + cell_product_type_id, + unit_price, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + if cell_product_price_code_exists(db, project_code, tenant_no): + raise ValueError(f"项目编码 {project_code} 已存在") + obj = BizCellProductPrice( + id=generate_uuid(), + project_name=project_name, + project_code=project_code, + cell_type_code=cell_type_code, + cell_type_name=cell_type_name, + spec_text=spec_text or "", + spec_id=spec_id or "", + cell_product_type_id=cell_product_type_id or "", + unit_price=unit_price, + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_cell_product_price( + db, + price_id, + *, + project_name=None, + project_code=None, + cell_type_code=None, + cell_type_name=None, + spec_text=None, + spec_id=None, + cell_product_type_id=None, + unit_price=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + obj = get_cell_product_price(db, price_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + if project_code is not None and project_code != obj.project_code: + if cell_product_price_code_exists(db, project_code, tenant_no, exclude_id=price_id): + raise ValueError(f"项目编码 {project_code} 已存在") + obj.project_code = project_code + if project_name is not None: + obj.project_name = project_name + if cell_type_code is not None: + obj.cell_type_code = cell_type_code + if cell_type_name is not None: + obj.cell_type_name = cell_type_name + if spec_text is not None: + obj.spec_text = spec_text + if spec_id is not None: + obj.spec_id = spec_id + if cell_product_type_id is not None: + obj.cell_product_type_id = cell_product_type_id + if unit_price is not None: + obj.unit_price = unit_price + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_cell_product_price(db, price_id, tenant_no=None): + obj = get_cell_product_price(db, price_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 4: BizCellProductProjectSystem 项目价格体系(主) +# ──────────────────────────────────────────── + +def _row_to_dict_project_system(row) -> Dict[str, Any]: + return { + "id": row.id, + "systemName": row.system_name or "", + "systemCode": row.system_code or "", + "status": row.status or "1", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_project_systems(db, *, page=1, page_size=10, keyword=None, tenant_no=None): + def _filtered(): + q0 = db.query(BizCellProductProjectSystem).filter(BizCellProductProjectSystem.del_flag == 0) + if tenant_no: + q0 = q0.filter(BizCellProductProjectSystem.tenant_no == tenant_no) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q0 = q0.filter( + or_( + BizCellProductProjectSystem.system_code.like(like), + BizCellProductProjectSystem.system_name.like(like), + ) + ) + return q0 + + total = _filtered().order_by(None).with_entities(func.count(BizCellProductProjectSystem.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = _filtered().order_by(BizCellProductProjectSystem.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all() + return [_row_to_dict_project_system(x) for x in rows], int(total) + + +def get_project_system(db, system_id, tenant_no=None): + q = db.query(BizCellProductProjectSystem).filter( + and_(BizCellProductProjectSystem.id == system_id, BizCellProductProjectSystem.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCellProductProjectSystem.tenant_no == tenant_no) + return q.first() + + +def project_system_code_exists(db, code, tenant_no, exclude_id=None): + q = db.query(BizCellProductProjectSystem).filter( + and_(BizCellProductProjectSystem.system_code == code, BizCellProductProjectSystem.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCellProductProjectSystem.tenant_no == tenant_no) + if exclude_id: + q = q.filter(BizCellProductProjectSystem.id != exclude_id) + return db.query(q.exists()).scalar() + + +def create_project_system(db, *, system_name, system_code, status="1", tenant_no="zkxw", order_num=None, remark=None, user_id=None): + if project_system_code_exists(db, system_code, tenant_no): + raise ValueError(f"体系编码 {system_code} 已存在") + obj = BizCellProductProjectSystem( + id=generate_uuid(), + system_name=system_name, + system_code=system_code, + status=status or "1", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_project_system(db, system_id, *, system_name=None, system_code=None, status=None, order_num=None, remark=None, user_id=None, tenant_no=None): + obj = get_project_system(db, system_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + if system_code is not None and system_code != obj.system_code: + if project_system_code_exists(db, system_code, tenant_no, exclude_id=system_id): + raise ValueError(f"体系编码 {system_code} 已存在") + obj.system_code = system_code + if system_name is not None: + obj.system_name = system_name + 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 + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_project_system(db, system_id, tenant_no=None): + obj = get_project_system(db, system_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 5: BizCellProductProjectSystemItem 项目价格体系明细 +# ──────────────────────────────────────────── + +def _row_to_dict_project_system_item(row) -> Dict[str, Any]: + return { + "id": row.id, + "projectSystemId": row.project_system_id or "", + "sourcePriceId": row.source_price_id or "", + "projectName": row.project_name or "", + "projectCode": row.project_code or "", + "cellTypeCode": row.cell_type_code or "", + "cellTypeName": row.cell_type_name or "", + "specText": row.spec_text or "", + "specId": row.spec_id or "", + "cellProductTypeId": row.cell_product_type_id or "", + "unitPrice": float(row.unit_price) if row.unit_price is not None else None, + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_project_system_items(db, project_system_id, tenant_no=None): + q = db.query(BizCellProductProjectSystemItem).filter( + and_( + BizCellProductProjectSystemItem.project_system_id == project_system_id, + BizCellProductProjectSystemItem.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizCellProductProjectSystemItem.tenant_no == tenant_no) + rows = q.order_by(BizCellProductProjectSystemItem.create_time.desc()).all() + return [_row_to_dict_project_system_item(x) for x in rows] + + +def create_project_system_item( + db, + *, + 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, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizCellProductProjectSystemItem( + id=generate_uuid(), + project_system_id=project_system_id, + source_price_id=source_price_id, + project_name=project_name, + project_code=project_code, + cell_type_code=cell_type_code, + cell_type_name=cell_type_name, + spec_text=spec_text or "", + spec_id=spec_id or "", + cell_product_type_id=cell_product_type_id or "", + unit_price=unit_price, + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_project_system_item( + db, + item_id, + *, + source_price_id=None, + project_name=None, + project_code=None, + cell_type_code=None, + cell_type_name=None, + spec_text=None, + spec_id=None, + cell_product_type_id=None, + unit_price=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + q = db.query(BizCellProductProjectSystemItem).filter( + and_(BizCellProductProjectSystemItem.id == item_id, BizCellProductProjectSystemItem.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCellProductProjectSystemItem.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if source_price_id is not None: + obj.source_price_id = source_price_id + if project_name is not None: + obj.project_name = project_name + if project_code is not None: + obj.project_code = project_code + if cell_type_code is not None: + obj.cell_type_code = cell_type_code + if cell_type_name is not None: + obj.cell_type_name = cell_type_name + if spec_text is not None: + obj.spec_text = spec_text + if spec_id is not None: + obj.spec_id = spec_id + if cell_product_type_id is not None: + obj.cell_product_type_id = cell_product_type_id + if unit_price is not None: + obj.unit_price = unit_price + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_project_system_item(db, item_id, tenant_no=None): + q = db.query(BizCellProductProjectSystemItem).filter( + and_(BizCellProductProjectSystemItem.id == item_id, BizCellProductProjectSystemItem.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCellProductProjectSystemItem.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 6: BizChannel 渠道信息 +# ──────────────────────────────────────────── + +def _row_to_dict_channel(row) -> Dict[str, Any]: + return { + "id": row.id, + "channelName": row.channel_name or "", + "creditCode": row.credit_code or "", + "channelType": row.channel_type or "", + "contactName": row.contact_name or "", + "contactPhone": row.contact_phone or "", + "contactEmail": row.contact_email or "", + "protocolAttachments": row.protocol_attachments or "", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_channels(db, *, page=1, page_size=10, keyword=None, tenant_no=None): + def _filtered(): + q0 = db.query(BizChannel).filter(BizChannel.del_flag == 0) + if tenant_no: + q0 = q0.filter(BizChannel.tenant_no == tenant_no) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q0 = q0.filter( + or_( + BizChannel.channel_name.like(like), + BizChannel.credit_code.like(like), + BizChannel.contact_name.like(like), + ) + ) + return q0 + + total = _filtered().order_by(None).with_entities(func.count(BizChannel.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = _filtered().order_by(BizChannel.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all() + return [_row_to_dict_channel(x) for x in rows], int(total) + + +def get_channel(db, channel_id, tenant_no=None): + q = db.query(BizChannel).filter(and_(BizChannel.id == channel_id, BizChannel.del_flag == 0)) + if tenant_no: + q = q.filter(BizChannel.tenant_no == tenant_no) + return q.first() + + +def create_channel( + db, + *, + channel_name, + credit_code=None, + channel_type=None, + contact_name=None, + contact_phone=None, + contact_email=None, + protocol_attachments=None, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizChannel( + id=generate_uuid(), + channel_name=channel_name, + credit_code=credit_code or "", + channel_type=channel_type or "", + contact_name=contact_name or "", + contact_phone=contact_phone or "", + contact_email=contact_email or "", + protocol_attachments=protocol_attachments or "", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_channel( + db, + channel_id, + *, + channel_name=None, + credit_code=None, + channel_type=None, + contact_name=None, + contact_phone=None, + contact_email=None, + protocol_attachments=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + obj = get_channel(db, channel_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + if channel_name is not None: + obj.channel_name = channel_name + if credit_code is not None: + obj.credit_code = credit_code + if channel_type is not None: + obj.channel_type = channel_type + if contact_name is not None: + obj.contact_name = contact_name + if contact_phone is not None: + obj.contact_phone = contact_phone + if contact_email is not None: + obj.contact_email = contact_email + if protocol_attachments is not None: + obj.protocol_attachments = protocol_attachments + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_channel(db, channel_id, tenant_no=None): + obj = get_channel(db, channel_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 7: BizChannelInvoice 渠道开票信息 +# ──────────────────────────────────────────── + +def _row_to_dict_channel_invoice(row) -> Dict[str, Any]: + return { + "id": row.id, + "channelId": row.channel_id or "", + "companyName": row.company_name or "", + "taxNo": row.tax_no or "", + "companyAddress": row.company_address or "", + "companyPhone": row.company_phone or "", + "bankName": row.bank_name or "", + "bankAccount": row.bank_account or "", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_channel_invoices(db, channel_id, tenant_no=None): + q = db.query(BizChannelInvoice).filter( + and_( + BizChannelInvoice.channel_id == channel_id, + BizChannelInvoice.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizChannelInvoice.tenant_no == tenant_no) + rows = q.order_by(BizChannelInvoice.create_time.desc()).all() + return [_row_to_dict_channel_invoice(x) for x in rows] + + +def create_channel_invoice( + db, + *, + channel_id, + company_name, + tax_no=None, + company_address=None, + company_phone=None, + bank_name=None, + bank_account=None, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizChannelInvoice( + id=generate_uuid(), + channel_id=channel_id, + company_name=company_name, + tax_no=tax_no or "", + company_address=company_address or "", + company_phone=company_phone or "", + bank_name=bank_name or "", + bank_account=bank_account or "", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_channel_invoice( + db, + invoice_id, + *, + company_name=None, + tax_no=None, + company_address=None, + company_phone=None, + bank_name=None, + bank_account=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + q = db.query(BizChannelInvoice).filter( + and_(BizChannelInvoice.id == invoice_id, BizChannelInvoice.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelInvoice.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if company_name is not None: + obj.company_name = company_name + if tax_no is not None: + obj.tax_no = tax_no + if company_address is not None: + obj.company_address = company_address + if company_phone is not None: + obj.company_phone = company_phone + if bank_name is not None: + obj.bank_name = bank_name + if bank_account is not None: + obj.bank_account = bank_account + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_channel_invoice(db, invoice_id, tenant_no=None): + q = db.query(BizChannelInvoice).filter( + and_(BizChannelInvoice.id == invoice_id, BizChannelInvoice.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelInvoice.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 8: BizChannelMailAddress 渠道邮寄地址 +# ──────────────────────────────────────────── + +def _row_to_dict_channel_mail_address(row) -> Dict[str, Any]: + return { + "id": row.id, + "channelId": row.channel_id or "", + "mailAddress": row.mail_address or "", + "mailContact": row.mail_contact or "", + "mailPhone": row.mail_phone or "", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_channel_mail_addresses(db, channel_id, tenant_no=None): + q = db.query(BizChannelMailAddress).filter( + and_( + BizChannelMailAddress.channel_id == channel_id, + BizChannelMailAddress.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizChannelMailAddress.tenant_no == tenant_no) + rows = q.order_by(BizChannelMailAddress.create_time.desc()).all() + return [_row_to_dict_channel_mail_address(x) for x in rows] + + +def create_channel_mail_address( + db, + *, + channel_id, + mail_address, + mail_contact=None, + mail_phone=None, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizChannelMailAddress( + id=generate_uuid(), + channel_id=channel_id, + mail_address=mail_address, + mail_contact=mail_contact or "", + mail_phone=mail_phone or "", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_channel_mail_address( + db, + address_id, + *, + mail_address=None, + mail_contact=None, + mail_phone=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + q = db.query(BizChannelMailAddress).filter( + and_(BizChannelMailAddress.id == address_id, BizChannelMailAddress.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelMailAddress.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if mail_address is not None: + obj.mail_address = mail_address + if mail_contact is not None: + obj.mail_contact = mail_contact + if mail_phone is not None: + obj.mail_phone = mail_phone + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_channel_mail_address(db, address_id, tenant_no=None): + q = db.query(BizChannelMailAddress).filter( + and_(BizChannelMailAddress.id == address_id, BizChannelMailAddress.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelMailAddress.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 9: BizChannelAgreement 渠道协议 +# ──────────────────────────────────────────── + +def _row_to_dict_channel_agreement(row) -> Dict[str, Any]: + def _fmt_date(v): + """CHAR(8) YYYYMMDD → YYYY-MM-DD""" + if not v or len(v) != 8: + return v or "" + return f"{v[:4]}-{v[4:6]}-{v[6:8]}" + + return { + "id": row.id, + "channelId": row.channel_id or "", + "agreementType": row.agreement_type or "", + "businessType": row.business_type or "", + "settleMode": row.settle_mode or "", + "creditLimitAmount": float(row.credit_limit_amount) if row.credit_limit_amount is not None else None, + "effectiveDate": _fmt_date(row.effective_date), + "expireDate": _fmt_date(row.expire_date), + "agreementStatus": row.agreement_status or "", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_channel_agreements(db, channel_id, tenant_no=None): + q = db.query(BizChannelAgreement).filter( + and_( + BizChannelAgreement.channel_id == channel_id, + BizChannelAgreement.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizChannelAgreement.tenant_no == tenant_no) + rows = q.order_by(BizChannelAgreement.create_time.desc()).all() + return [_row_to_dict_channel_agreement(x) for x in rows] + + +def create_channel_agreement( + db, + *, + channel_id, + agreement_type, + business_type=None, + settle_mode=None, + credit_limit_amount=None, + effective_date=None, + expire_date=None, + agreement_status="dsx", + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + _ed = (effective_date or "").replace("-", "") if effective_date else "" + _xd = (expire_date or "").replace("-", "") if expire_date else "" + obj = BizChannelAgreement( + id=generate_uuid(), + channel_id=channel_id, + agreement_type=agreement_type, + business_type=business_type or "", + settle_mode=settle_mode or "", + credit_limit_amount=credit_limit_amount, + effective_date=_ed, + expire_date=_xd, + agreement_status=agreement_status or "dsx", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_channel_agreement( + db, + agreement_id, + *, + agreement_type=None, + business_type=None, + settle_mode=None, + credit_limit_amount=None, + effective_date=None, + expire_date=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + q = db.query(BizChannelAgreement).filter( + and_(BizChannelAgreement.id == agreement_id, BizChannelAgreement.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelAgreement.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if agreement_type is not None: + obj.agreement_type = agreement_type + if business_type is not None: + obj.business_type = business_type + if settle_mode is not None: + obj.settle_mode = settle_mode + if credit_limit_amount is not None: + obj.credit_limit_amount = credit_limit_amount + if effective_date is not None: + obj.effective_date = effective_date.replace("-", "") if effective_date else "" + if expire_date is not None: + obj.expire_date = expire_date.replace("-", "") if expire_date else "" + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_channel_agreement(db, agreement_id, tenant_no=None): + q = db.query(BizChannelAgreement).filter( + and_(BizChannelAgreement.id == agreement_id, BizChannelAgreement.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelAgreement.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +def activate_channel_agreement(db, agreement_id, tenant_no=None, user_id=None): + """生效协议:仅待生效(dsx)可变为生效中(sxz)""" + q = db.query(BizChannelAgreement).filter( + and_(BizChannelAgreement.id == agreement_id, BizChannelAgreement.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelAgreement.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if (obj.agreement_status or "").strip() != "dsx": + raise ValueError("仅待生效状态的协议可执行生效操作") + obj.agreement_status = "sxz" + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def void_channel_agreement(db, agreement_id, tenant_no=None, user_id=None): + """作废协议:设置 agreement_status='yzf'""" + q = db.query(BizChannelAgreement).filter( + and_(BizChannelAgreement.id == agreement_id, BizChannelAgreement.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelAgreement.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.agreement_status = "yzf" + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +# ──────────────────────────────────────────── +# Entity 10: BizChannelAgreementProductPrice 渠道协议产品价格 +# ──────────────────────────────────────────── + +def _row_to_dict_channel_agreement_price(row) -> Dict[str, Any]: + return { + "id": row.id, + "channelAgreementId": row.channel_agreement_id or "", + "projectName": row.project_name or "", + "projectCode": row.project_code or "", + "cellTypeCode": row.cell_type_code or "", + "cellTypeName": row.cell_type_name or "", + "specText": row.spec_text or "", + "originalPrice": float(row.original_price) if row.original_price is not None else None, + "agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None, + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_channel_agreement_prices(db, channel_agreement_id, tenant_no=None): + q = db.query(BizChannelAgreementProductPrice).filter( + and_( + BizChannelAgreementProductPrice.channel_agreement_id == channel_agreement_id, + BizChannelAgreementProductPrice.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizChannelAgreementProductPrice.tenant_no == tenant_no) + rows = q.order_by(BizChannelAgreementProductPrice.create_time.desc()).all() + return [_row_to_dict_channel_agreement_price(x) for x in rows] + + +def create_channel_agreement_price( + db, + *, + channel_agreement_id, + project_name=None, + project_code=None, + cell_type_code=None, + cell_type_name=None, + spec_text=None, + original_price=None, + agreement_price=None, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizChannelAgreementProductPrice( + id=generate_uuid(), + channel_agreement_id=channel_agreement_id, + project_name=project_name or "", + project_code=project_code or "", + cell_type_code=cell_type_code or "", + cell_type_name=cell_type_name or "", + spec_text=spec_text or "", + original_price=original_price, + agreement_price=agreement_price, + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_channel_agreement_price( + db, + price_id, + *, + project_name=None, + project_code=None, + cell_type_code=None, + cell_type_name=None, + spec_text=None, + original_price=None, + agreement_price=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + q = db.query(BizChannelAgreementProductPrice).filter( + and_(BizChannelAgreementProductPrice.id == price_id, BizChannelAgreementProductPrice.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelAgreementProductPrice.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if project_name is not None: + obj.project_name = project_name + if project_code is not None: + obj.project_code = project_code + if cell_type_code is not None: + obj.cell_type_code = cell_type_code + if cell_type_name is not None: + obj.cell_type_name = cell_type_name + if spec_text is not None: + obj.spec_text = spec_text + if original_price is not None: + obj.original_price = original_price + if agreement_price is not None: + obj.agreement_price = agreement_price + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_channel_agreement_price(db, price_id, tenant_no=None): + q = db.query(BizChannelAgreementProductPrice).filter( + and_(BizChannelAgreementProductPrice.id == price_id, BizChannelAgreementProductPrice.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizChannelAgreementProductPrice.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 11: BizCheckupInstitution 体检机构 +# ──────────────────────────────────────────── + +def _row_to_dict_checkup_institution(row) -> Dict[str, Any]: + return { + "id": row.id, + "institutionName": row.institution_name or "", + "institutionAddress": row.institution_address or "", + "contactName": row.contact_name or "", + "contactPhone": row.contact_phone or "", + "status": row.status or "1", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_checkup_institutions(db, *, page=1, page_size=10, keyword=None, tenant_no=None): + def _filtered(): + q0 = db.query(BizCheckupInstitution).filter(BizCheckupInstitution.del_flag == 0) + if tenant_no: + q0 = q0.filter(BizCheckupInstitution.tenant_no == tenant_no) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q0 = q0.filter( + or_( + BizCheckupInstitution.institution_name.like(like), + BizCheckupInstitution.contact_name.like(like), + ) + ) + return q0 + + total = _filtered().order_by(None).with_entities(func.count(BizCheckupInstitution.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = _filtered().order_by(BizCheckupInstitution.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all() + return [_row_to_dict_checkup_institution(x) for x in rows], int(total) + + +def get_checkup_institution(db, institution_id, tenant_no=None): + q = db.query(BizCheckupInstitution).filter( + and_(BizCheckupInstitution.id == institution_id, BizCheckupInstitution.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCheckupInstitution.tenant_no == tenant_no) + return q.first() + + +def create_checkup_institution( + db, + *, + institution_name, + institution_address=None, + contact_name=None, + contact_phone=None, + status="1", + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizCheckupInstitution( + id=generate_uuid(), + institution_name=institution_name, + institution_address=institution_address or "", + contact_name=contact_name or "", + contact_phone=contact_phone or "", + status=status or "1", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_checkup_institution( + db, + institution_id, + *, + institution_name=None, + institution_address=None, + contact_name=None, + contact_phone=None, + status=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + obj = get_checkup_institution(db, institution_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + if institution_name is not None: + obj.institution_name = institution_name + if institution_address is not None: + obj.institution_address = institution_address + if contact_name is not None: + obj.contact_name = contact_name + if contact_phone is not None: + obj.contact_phone = contact_phone + 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 + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_checkup_institution(db, institution_id, tenant_no=None): + obj = get_checkup_institution(db, institution_id, tenant_no) + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 12: BizCheckupInstitutionInvoice 体检机构开票信息 +# ──────────────────────────────────────────── + +def _row_to_dict_checkup_institution_invoice(row) -> Dict[str, Any]: + return { + "id": row.id, + "checkupInstitutionId": row.checkup_institution_id or "", + "companyName": row.company_name or "", + "taxNo": row.tax_no or "", + "companyAddress": row.company_address or "", + "companyPhone": row.company_phone or "", + "bankName": row.bank_name or "", + "bankAccount": row.bank_account or "", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_checkup_institution_invoices(db, checkup_institution_id, tenant_no=None): + q = db.query(BizCheckupInstitutionInvoice).filter( + and_( + BizCheckupInstitutionInvoice.checkup_institution_id == checkup_institution_id, + BizCheckupInstitutionInvoice.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionInvoice.tenant_no == tenant_no) + rows = q.order_by(BizCheckupInstitutionInvoice.create_time.desc()).all() + return [_row_to_dict_checkup_institution_invoice(x) for x in rows] + + +def create_checkup_institution_invoice( + db, + *, + checkup_institution_id, + company_name, + tax_no=None, + company_address=None, + company_phone=None, + bank_name=None, + bank_account=None, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizCheckupInstitutionInvoice( + id=generate_uuid(), + checkup_institution_id=checkup_institution_id, + company_name=company_name, + tax_no=tax_no or "", + company_address=company_address or "", + company_phone=company_phone or "", + bank_name=bank_name or "", + bank_account=bank_account or "", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_checkup_institution_invoice( + db, + invoice_id, + *, + company_name=None, + tax_no=None, + company_address=None, + company_phone=None, + bank_name=None, + bank_account=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + q = db.query(BizCheckupInstitutionInvoice).filter( + and_(BizCheckupInstitutionInvoice.id == invoice_id, BizCheckupInstitutionInvoice.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionInvoice.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if company_name is not None: + obj.company_name = company_name + if tax_no is not None: + obj.tax_no = tax_no + if company_address is not None: + obj.company_address = company_address + if company_phone is not None: + obj.company_phone = company_phone + if bank_name is not None: + obj.bank_name = bank_name + if bank_account is not None: + obj.bank_account = bank_account + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = remark + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_checkup_institution_invoice(db, invoice_id, tenant_no=None): + q = db.query(BizCheckupInstitutionInvoice).filter( + and_(BizCheckupInstitutionInvoice.id == invoice_id, BizCheckupInstitutionInvoice.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionInvoice.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 13: BizCheckupInstitutionProject 体检机构项目及价格 +# ──────────────────────────────────────────── + +def _row_to_dict_checkup_institution_project(row) -> Dict[str, Any]: + return { + "id": row.id, + "checkupInstitutionId": row.checkup_institution_id or "", + "projectName": row.project_name or "", + "projectFee": float(row.project_fee) if row.project_fee is not None else None, + "status": row.status or "1", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def list_checkup_institution_projects(db, checkup_institution_id, tenant_no=None): + q = db.query(BizCheckupInstitutionProject).filter( + and_( + BizCheckupInstitutionProject.checkup_institution_id == checkup_institution_id, + BizCheckupInstitutionProject.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionProject.tenant_no == tenant_no) + rows = q.order_by(BizCheckupInstitutionProject.create_time.desc()).all() + return [_row_to_dict_checkup_institution_project(x) for x in rows] + + +def create_checkup_institution_project( + db, + *, + checkup_institution_id, + project_name, + project_fee=None, + status="1", + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + obj = BizCheckupInstitutionProject( + id=generate_uuid(), + checkup_institution_id=checkup_institution_id, + project_name=project_name, + project_fee=project_fee, + status=status or "1", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_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_checkup_institution_project( + db, + project_id, + *, + project_name=None, + project_fee=None, + status=None, + order_num=None, + remark=None, + user_id=None, + tenant_no=None, +): + q = db.query(BizCheckupInstitutionProject).filter( + and_(BizCheckupInstitutionProject.id == project_id, BizCheckupInstitutionProject.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionProject.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + if project_name is not None: + obj.project_name = project_name + if project_fee is not None: + obj.project_fee = project_fee + 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 + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def soft_delete_checkup_institution_project(db, project_id, tenant_no=None): + q = db.query(BizCheckupInstitutionProject).filter( + and_(BizCheckupInstitutionProject.id == project_id, BizCheckupInstitutionProject.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionProject.tenant_no == tenant_no) + obj = q.first() + if not obj: + raise ValueError("记录不存在") + obj.del_flag = 1 + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# ──────────────────────────────────────────── +# Entity 14: BizCheckupInstitutionPackage 体检机构项目套餐 +# ──────────────────────────────────────────── + +def _sum_package_item_fees(items: List[Dict[str, Any]]) -> float: + total = 0.0 + for it in items: + fee = it.get("project_fee") + if fee is None and "projectFee" in it: + fee = it.get("projectFee") + total += float(fee) if fee is not None else 0.0 + return total + + +def _row_to_dict_checkup_institution_package( + row, + *, + total_fee: Optional[float] = None, + item_count: Optional[int] = None, +) -> Dict[str, Any]: + d = { + "id": row.id, + "checkupInstitutionId": row.checkup_institution_id or "", + "packageName": row.package_name or "", + "packageCode": row.package_code or "", + "status": row.status or "1", + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + if total_fee is not None: + d["totalFee"] = total_fee + if item_count is not None: + d["itemCount"] = item_count + return d + + +def _row_to_dict_checkup_institution_package_item( + row, + *, + source_project_deleted: bool = False, + source_project_status: Optional[str] = None, +) -> Dict[str, Any]: + return { + "id": row.id, + "packageId": row.package_id or "", + "checkupInstitutionProjectId": row.checkup_institution_project_id or "", + "projectName": row.project_name or "", + "projectFee": float(row.project_fee) if row.project_fee is not None else None, + "status": source_project_status if source_project_status is not None else "1", + "sourceProjectDeleted": source_project_deleted, + "tenantNo": row.tenant_no or "", + "orderNum": row.order_num if row.order_num is not None else 0, + "remark": row.remark or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + + +def checkup_package_code_exists(db, institution_id, code, tenant_no, exclude_id=None): + q = db.query(BizCheckupInstitutionPackage).filter( + and_( + BizCheckupInstitutionPackage.checkup_institution_id == institution_id, + BizCheckupInstitutionPackage.package_code == code, + BizCheckupInstitutionPackage.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionPackage.tenant_no == tenant_no) + if exclude_id: + q = q.filter(BizCheckupInstitutionPackage.id != exclude_id) + return db.query(q.exists()).scalar() + + +def _get_checkup_project_for_package(db, project_id, institution_id, tenant_no=None): + q = db.query(BizCheckupInstitutionProject).filter( + BizCheckupInstitutionProject.id == project_id, + BizCheckupInstitutionProject.checkup_institution_id == institution_id, + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionProject.tenant_no == tenant_no) + return q.first() + + +def _validate_package_items_for_save( + db, + institution_id, + items, + tenant_no=None, + existing_item_project_ids=None, +): + if not items: + raise ValueError("套餐至少包含一个体检项目") + existing_ids = set(existing_item_project_ids or []) + seen = set() + for it in items: + pid = it.get("checkup_institution_project_id") or it.get("checkupInstitutionProjectId") + if not pid: + raise ValueError("套餐明细缺少机构项目") + if pid in seen: + raise ValueError("套餐内不能重复添加同一体检项目") + seen.add(pid) + proj = _get_checkup_project_for_package(db, pid, institution_id, tenant_no) + if not proj: + raise ValueError("存在无效的体检项目") + if pid in existing_ids: + continue + if proj.del_flag != 0: + raise ValueError("不能新添加已删除的体检项目") + if (proj.status or "1") != "1": + raise ValueError("套餐不能包含已停用的体检项目") + + +def _list_package_items_raw(db, package_id, tenant_no=None): + q = db.query(BizCheckupInstitutionPackageItem).filter( + and_( + BizCheckupInstitutionPackageItem.package_id == package_id, + BizCheckupInstitutionPackageItem.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionPackageItem.tenant_no == tenant_no) + return q.order_by(BizCheckupInstitutionPackageItem.order_num.asc(), BizCheckupInstitutionPackageItem.create_time.asc()).all() + + +def _package_items_to_dicts(db, institution_id, item_rows, tenant_no=None): + result = [] + for row in item_rows: + proj = _get_checkup_project_for_package( + db, row.checkup_institution_project_id, institution_id, tenant_no + ) + deleted = proj is None or proj.del_flag != 0 + proj_status = (proj.status or "1") if proj else "1" + result.append( + _row_to_dict_checkup_institution_package_item( + row, + source_project_deleted=deleted, + source_project_status=proj_status, + ) + ) + return result + + +def _insert_package_items( + db, + *, + package_id, + institution_id, + items, + tenant_no, + user_id=None, +): + for idx, it in enumerate(items): + pid = it.get("checkup_institution_project_id") or it.get("checkupInstitutionProjectId") + proj = _get_checkup_project_for_package(db, pid, institution_id, tenant_no) + name = (it.get("project_name") or it.get("projectName") or "").strip() + if not name and proj: + name = proj.project_name or "" + fee = it.get("project_fee") + if fee is None: + fee = it.get("projectFee") + obj = BizCheckupInstitutionPackageItem( + id=generate_uuid(), + package_id=package_id, + checkup_institution_project_id=pid, + project_name=name, + project_fee=fee, + tenant_no=tenant_no, + order_num=it.get("order_num") if it.get("order_num") is not None else it.get("orderNum") or (idx + 1), + remark=it.get("remark") or "", + create_by=user_id, + create_time=datetime.now(), + update_by=user_id, + update_time=datetime.now(), + del_flag=0, + ) + db.add(obj) + + +def list_checkup_institution_packages(db, checkup_institution_id, tenant_no=None): + q = db.query(BizCheckupInstitutionPackage).filter( + and_( + BizCheckupInstitutionPackage.checkup_institution_id == checkup_institution_id, + BizCheckupInstitutionPackage.del_flag == 0, + ) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionPackage.tenant_no == tenant_no) + rows = q.order_by(BizCheckupInstitutionPackage.create_time.desc()).all() + out = [] + for row in rows: + item_rows = _list_package_items_raw(db, row.id, tenant_no) + fees = [float(x.project_fee) if x.project_fee is not None else 0.0 for x in item_rows] + out.append( + _row_to_dict_checkup_institution_package( + row, + total_fee=sum(fees), + item_count=len(item_rows), + ) + ) + return out + + +def get_checkup_institution_package(db, package_id, tenant_no=None): + q = db.query(BizCheckupInstitutionPackage).filter( + and_(BizCheckupInstitutionPackage.id == package_id, BizCheckupInstitutionPackage.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizCheckupInstitutionPackage.tenant_no == tenant_no) + return q.first() + + +def get_checkup_institution_package_detail(db, package_id, tenant_no=None): + pkg = get_checkup_institution_package(db, package_id, tenant_no) + if not pkg: + return None + item_rows = _list_package_items_raw(db, package_id, tenant_no) + items = _package_items_to_dicts(db, pkg.checkup_institution_id, item_rows, tenant_no) + fees = [float(x.project_fee) if x.project_fee is not None else 0.0 for x in item_rows] + d = _row_to_dict_checkup_institution_package(pkg, total_fee=sum(fees), item_count=len(item_rows)) + d["items"] = items + return d + + +def create_checkup_institution_package( + db, + *, + checkup_institution_id, + package_name, + package_code, + status="1", + items, + tenant_no="zkxw", + order_num=None, + remark=None, + user_id=None, +): + code = (package_code or "").strip() + if not code: + raise ValueError("套餐编码不能为空") + if checkup_package_code_exists(db, checkup_institution_id, code, tenant_no): + raise ValueError(f"套餐编码 {code} 已存在") + _validate_package_items_for_save(db, checkup_institution_id, items, tenant_no) + pkg = BizCheckupInstitutionPackage( + id=generate_uuid(), + checkup_institution_id=checkup_institution_id, + package_name=package_name, + package_code=code, + status=status or "1", + tenant_no=tenant_no, + order_num=order_num or 0, + remark=remark or "", + create_by=user_id, + create_time=datetime.now(), + update_by=user_id, + update_time=datetime.now(), + del_flag=0, + ) + db.add(pkg) + db.flush() + _insert_package_items( + db, + package_id=pkg.id, + institution_id=checkup_institution_id, + items=items, + tenant_no=tenant_no, + user_id=user_id, + ) + db.commit() + db.refresh(pkg) + return get_checkup_institution_package_detail(db, pkg.id, tenant_no) + + +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, +): + pkg = get_checkup_institution_package(db, package_id, tenant_no) + if not pkg: + raise ValueError("记录不存在") + institution_id = pkg.checkup_institution_id + if package_code is not None: + code = package_code.strip() + if not code: + raise ValueError("套餐编码不能为空") + if code != pkg.package_code and checkup_package_code_exists( + db, institution_id, code, tenant_no, exclude_id=package_id + ): + raise ValueError(f"套餐编码 {code} 已存在") + pkg.package_code = code + if package_name is not None: + pkg.package_name = package_name + if status is not None: + pkg.status = status + if order_num is not None: + pkg.order_num = order_num + if remark is not None: + pkg.remark = remark + if items is not None: + old_rows = _list_package_items_raw(db, package_id, tenant_no) + existing_pids = {r.checkup_institution_project_id for r in old_rows} + _validate_package_items_for_save( + db, + institution_id, + items, + tenant_no, + existing_item_project_ids=existing_pids, + ) + # 全量替换:物理删除旧明细,避免 uk(package_id, project_id) 与软删行冲突 + q_items = db.query(BizCheckupInstitutionPackageItem).filter( + BizCheckupInstitutionPackageItem.package_id == package_id + ) + if tenant_no: + q_items = q_items.filter(BizCheckupInstitutionPackageItem.tenant_no == tenant_no) + for row in q_items.all(): + db.delete(row) + db.flush() + _insert_package_items( + db, + package_id=package_id, + institution_id=institution_id, + items=items, + tenant_no=tenant_no or pkg.tenant_no, + user_id=user_id, + ) + pkg.update_by = user_id + pkg.update_time = datetime.now() + db.add(pkg) + db.commit() + return get_checkup_institution_package_detail(db, package_id, tenant_no) + + +def soft_delete_checkup_institution_package(db, package_id, tenant_no=None): + pkg = get_checkup_institution_package(db, package_id, tenant_no) + if not pkg: + raise ValueError("记录不存在") + now = datetime.now() + pkg.del_flag = 1 + pkg.update_time = now + db.add(pkg) + for row in _list_package_items_raw(db, package_id, tenant_no): + row.del_flag = 1 + row.update_time = now + db.add(row) + db.commit() + + +# ──────────────────────────────────────────── +# Shared utility +# ──────────────────────────────────────────── + +def total_pages(total, page_size): + if total <= 0: + return 0 + return int(math.ceil(total / float(page_size))) diff --git a/backend/app/crud/process.py b/backend/app/crud/process.py new file mode 100644 index 00000000..d02378d9 --- /dev/null +++ b/backend/app/crud/process.py @@ -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))) diff --git a/backend/app/crud/process_category.py b/backend/app/crud/process_category.py new file mode 100644 index 00000000..613dce0a --- /dev/null +++ b/backend/app/crud/process_category.py @@ -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))) diff --git a/backend/app/crud/reagent.py b/backend/app/crud/reagent.py new file mode 100644 index 00000000..fff27fe5 --- /dev/null +++ b/backend/app/crud/reagent.py @@ -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))) diff --git a/backend/app/crud/research_donation.py b/backend/app/crud/research_donation.py new file mode 100644 index 00000000..031b9329 --- /dev/null +++ b/backend/app/crud/research_donation.py @@ -0,0 +1,1692 @@ +"""科研捐赠管理 CRUD(捐赠单 / 审核 / 状态变更 / 转换)""" +from __future__ import annotations + +import math +from datetime import datetime +from decimal import Decimal +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, parse_datetime +from app.crud.base import camel_to_snake +from app.models.models import ( + BizCollectionProtocol, + BizCollectionRegion, + BizCollectionHospital, + BizProtocolCollectionInfo, + BizProtocolPartyCustomer, + BizProtocolSample, + BizProtocolSampleTransport, + SysUser, +) +from app.models.research_donation import ( + BizResearchDonation, + BizResearchDonationAudit, + BizResearchDonationCollection, + BizResearchDonationParty, + BizResearchDonationSample, + BizResearchDonationSampleTransport, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def total_pages(total: int, page_size: int) -> int: + if total <= 0: + return 0 + return int(math.ceil(total / float(page_size))) + + +def _fmt_dt(t: Optional[datetime]) -> str: + return t.strftime("%Y-%m-%d %H:%M:%S") if t else "" + + +def _dec(v: Any) -> Optional[float]: + if v is None: + return None + if isinstance(v, Decimal): + return float(v) + return float(v) + + +def _optional_decimal(v: Any) -> Optional[Decimal]: + if v is None or v == "": + return None + return Decimal(str(v)) + + +_SNAKE_ALIASES = { + "apgar1min": "apgar_1min", + "apgar5min": "apgar_5min", +} + + +def _normalize_fields(fields: Optional[dict]) -> dict: + """将前端 camelCase 字段名转为数据库 snake_case。""" + if not fields: + return {} + out: Dict[str, Any] = {} + for k, v in fields.items(): + sk = _SNAKE_ALIASES.get(k) or camel_to_snake(k) + out[sk] = v + return out + + +def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: + ids = list({x for x in user_ids if x}) + if not ids: + return {} + rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all() + return {u.id: u.nick_name or "" for u in rows} + + +# --------------------------------------------------------------------------- +# ORM → dict serializers +# --------------------------------------------------------------------------- + + +def _donation_to_dict(d: BizResearchDonation) -> Dict[str, Any]: + return { + "id": d.id, + "donationNo": d.donation_no or "", + "donationStatus": d.donation_status or "", + "donationDate": (d.donation_date or "").strip() if d.donation_date else "", + "sourceType": d.source_type or "direct", + "sourceProtocolId": d.source_protocol_id or "", + "consentFormUrls": d.consent_form_urls or "", + "attachmentUrls": d.attachment_urls or "", + "collectorUserId": d.collector_user_id or "", + "effectiveTime": _fmt_dt(d.effective_time), + "effectiveDesc": d.effective_desc or "", + "expireTime": _fmt_dt(d.expire_time), + "expireDesc": d.expire_desc or "", + "voidTime": _fmt_dt(d.void_time), + "voidDesc": d.void_desc or "", + "toStorageTime": _fmt_dt(d.to_storage_time), + "toStorageDesc": d.to_storage_desc or "", + "tenantNo": d.tenant_no, + "orderNum": d.order_num if d.order_num is not None else 0, + "remark": d.remark or "", + "createTime": _fmt_dt(d.create_time), + "updateTime": _fmt_dt(d.update_time), + } + + +def _audit_to_dict( + a: BizResearchDonationAudit, + *, + donation_no: str = "", + auditor_user_name: str = "", +) -> Dict[str, Any]: + return { + "id": a.id, + "researchDonationId": a.research_donation_id, + "donationNo": donation_no, + "auditorUserId": a.auditor_user_id or "", + "auditorUserName": auditor_user_name or "", + "auditRemark": a.audit_remark or "", + "auditStatus": a.audit_status or "", + "tenantNo": a.tenant_no, + "orderNum": a.order_num if a.order_num is not None else 0, + "remark": a.remark or "", + "createTime": _fmt_dt(a.create_time), + "updateTime": _fmt_dt(a.update_time), + } + + +def _party_to_dict(p: BizResearchDonationParty) -> Dict[str, Any]: + return { + "id": p.id, + "researchDonationId": p.research_donation_id, + "customerId": p.customer_id or "", + "motherName": p.mother_name or "", + "motherPhone": p.mother_phone or "", + "motherIdCard": p.mother_id_card or "", + "motherBirthYm": (p.mother_birth_ym or "").strip() if p.mother_birth_ym else "", + "motherEthnic": p.mother_ethnic or "", + "motherAddress": p.mother_address or "", + "fatherName": p.father_name or "", + "fatherPhone": p.father_phone or "", + "fatherIdCard": p.father_id_card or "", + "newbornName": p.newborn_name or "", + "newbornIdCard": p.newborn_id_card or "", + "newbornDeliveryTime": _fmt_dt(p.newborn_delivery_time), + "newbornSex": p.newborn_sex or "", + "deliveryMode": p.delivery_mode or "", + "birthOrder": p.birth_order, + "newbornWeightGram": p.newborn_weight_gram, + "apgar1min": _dec(p.apgar_1min), + "apgar5min": _dec(p.apgar_5min), + "collectorUserId": p.collector_user_id or "", + "tenantNo": p.tenant_no, + "orderNum": p.order_num if p.order_num is not None else 0, + "remark": p.remark or "", + "createTime": _fmt_dt(p.create_time), + "updateTime": _fmt_dt(p.update_time), + } + + +def _collection_to_dict(c: BizResearchDonationCollection) -> Dict[str, Any]: + return { + "id": c.id, + "researchDonationId": c.research_donation_id, + "regionId": c.region_id or "", + "hospitalId": c.hospital_id or "", + "medicalStaff": c.medical_staff or "", + "salesUserId": c.sales_user_id or "", + "sampleCollectTime": _fmt_dt(c.sample_collect_time), + "maternalBloodCollectTime": _fmt_dt(c.maternal_blood_collect_time), + "tenantNo": c.tenant_no, + "orderNum": c.order_num if c.order_num is not None else 0, + "remark": c.remark or "", + "createTime": _fmt_dt(c.create_time), + "updateTime": _fmt_dt(c.update_time), + } + + +def _sample_to_dict(s: BizResearchDonationSample) -> Dict[str, Any]: + return { + "id": s.id, + "researchDonationId": s.research_donation_id, + "sampleTypeId": s.sample_type_id or "", + "sampleCodeId": s.sample_code_id or "", + "sampleCode": s.sample_code or "", + "collectAmount": _dec(s.collect_amount), + "unitId": s.unit_id or "", + "storageYears": s.storage_years, + "sampleStatus": (s.sample_status or "").strip() if (s.sample_status or "").strip() else "", + "storageInDate": (s.storage_in_date or "").strip() if s.storage_in_date else "", + "detectStartDate": (s.detect_start_date or "").strip() if s.detect_start_date else "", + "voidDate": (s.void_date or "").strip() if s.void_date else "", + "storageExpireDate": (s.storage_expire_date or "").strip() if s.storage_expire_date else "", + "tenantNo": s.tenant_no, + "orderNum": s.order_num if s.order_num is not None else 0, + "remark": s.remark or "", + "createTime": _fmt_dt(s.create_time), + "updateTime": _fmt_dt(s.update_time), + } + + +def _transport_to_dict(t: BizResearchDonationSampleTransport) -> Dict[str, Any]: + return { + "id": t.id, + "researchDonationId": t.research_donation_id, + "sampleId": t.sample_id, + "transportType": t.transport_type or "", + "expressCompanyId": t.express_company_id or "", + "waybillNo": t.waybill_no or "", + "destSiteId": t.dest_site_id or "", + "expressFee": _dec(t.express_fee), + "etaTime": _fmt_dt(t.eta_time), + "carrierName": t.carrier_name or "", + "carrierPhone": t.carrier_phone or "", + "remark": t.remark or "", + "orderNum": t.order_num if t.order_num is not None else 0, + "createTime": _fmt_dt(t.create_time), + "updateTime": _fmt_dt(t.update_time), + } + + +# --------------------------------------------------------------------------- +# Sub-resource queries +# --------------------------------------------------------------------------- + + +def get_party( + db: Session, research_donation_id: str, tenant_no: str +) -> Optional[BizResearchDonationParty]: + return ( + db.query(BizResearchDonationParty) + .filter( + and_( + BizResearchDonationParty.research_donation_id == research_donation_id, + BizResearchDonationParty.tenant_no == tenant_no, + BizResearchDonationParty.del_flag == 0, + ) + ) + .first() + ) + + +def get_collection( + db: Session, research_donation_id: str, tenant_no: str +) -> Optional[BizResearchDonationCollection]: + return ( + db.query(BizResearchDonationCollection) + .filter( + and_( + BizResearchDonationCollection.research_donation_id == research_donation_id, + BizResearchDonationCollection.tenant_no == tenant_no, + BizResearchDonationCollection.del_flag == 0, + ) + ) + .first() + ) + + +def list_samples( + db: Session, research_donation_id: str, tenant_no: str +) -> List[BizResearchDonationSample]: + return ( + db.query(BizResearchDonationSample) + .filter( + and_( + BizResearchDonationSample.research_donation_id == research_donation_id, + BizResearchDonationSample.tenant_no == tenant_no, + BizResearchDonationSample.del_flag == 0, + ) + ) + .order_by( + BizResearchDonationSample.order_num.asc(), + BizResearchDonationSample.create_time.asc(), + ) + .all() + ) + + +def list_sample_transports( + db: Session, research_donation_id: str, tenant_no: str +) -> List[BizResearchDonationSampleTransport]: + return ( + db.query(BizResearchDonationSampleTransport) + .filter( + and_( + BizResearchDonationSampleTransport.research_donation_id == research_donation_id, + BizResearchDonationSampleTransport.tenant_no == tenant_no, + BizResearchDonationSampleTransport.del_flag == 0, + ) + ) + .order_by( + BizResearchDonationSampleTransport.order_num.asc(), + BizResearchDonationSampleTransport.create_time.asc(), + ) + .all() + ) + + +# --------------------------------------------------------------------------- +# 1. list_donations +# --------------------------------------------------------------------------- + + +def list_donations( + db: Session, + *, + tenant_no: str, + page: int = 1, + page_size: int = 10, + keyword: Optional[str] = None, + donation_status: Optional[str] = None, + collector_user_id: Optional[str] = None, +) -> Dict[str, Any]: + """分页查询科研捐赠列表,含母亲姓名/电话(join party 表)。""" + def _base(): + q = ( + db.query(BizResearchDonation) + .filter(BizResearchDonation.del_flag == 0) + ) + if tenant_no: + q = q.filter(BizResearchDonation.tenant_no == tenant_no) + if collector_user_id: + q = q.filter(BizResearchDonation.collector_user_id == collector_user_id) + if donation_status: + q = q.filter(BizResearchDonation.donation_status == donation_status.strip()) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + # 子查询:party 表中 mother_name 匹配的 donation_id 集合 + party_match = ( + db.query(BizResearchDonationParty.research_donation_id) + .filter( + BizResearchDonationParty.del_flag == 0, + BizResearchDonationParty.mother_name.like(like), + ) + .subquery() + ) + q = q.filter( + or_( + BizResearchDonation.donation_no.like(like), + BizResearchDonation.id.in_(party_match), + ) + ) + return q + + total = ( + _base() + .order_by(None) + .with_entities(func.count(BizResearchDonation.id)) + .scalar() + ) or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + _base() + .order_by(BizResearchDonation.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + # 批量加载 party + donation_ids = [r.id for r in rows] + party_map: Dict[str, Dict[str, Any]] = {} + if donation_ids: + party_rows = ( + db.query(BizResearchDonationParty) + .filter( + BizResearchDonationParty.research_donation_id.in_(donation_ids), + BizResearchDonationParty.del_flag == 0, + ) + .all() + ) + for p in party_rows: + party_map[p.research_donation_id] = _party_to_dict(p) + + # 批量解析采集专员名称 + collector_ids = list({r.collector_user_id for r in rows if r.collector_user_id}) + user_name_map = _user_nick_name_map(db, collector_ids) + + result = [] + for r in rows: + d = _donation_to_dict(r) + d["collectorUserName"] = user_name_map.get(r.collector_user_id or "", "") + pc = party_map.get(r.id) + if pc: + d["motherName"] = pc.get("motherName", "") + d["motherPhone"] = pc.get("motherPhone", "") + else: + d["motherName"] = "" + d["motherPhone"] = "" + result.append(d) + + return { + "list": result, + "total": int(total), + "page": page, + "pageSize": page_size, + "totalPages": total_pages(int(total), page_size), + } + + +# --------------------------------------------------------------------------- +# 2. get_donation_detail +# --------------------------------------------------------------------------- + + +def get_donation_detail( + db: Session, + donation_id: str, + tenant_no: str, +) -> Optional[Dict[str, Any]]: + """查询单条捐赠详情,含全部子资源嵌套。""" + main = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == donation_id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if not main: + return None + + out: Dict[str, Any] = {"donation": _donation_to_dict(main)} + + # 采集专员名称 + user_ids: List[str] = [] + if main.collector_user_id: + user_ids.append(main.collector_user_id) + + # party + party = get_party(db, donation_id, tenant_no) + out["party"] = _party_to_dict(party) if party else None + if party and party.collector_user_id: + user_ids.append(party.collector_user_id) + + # collection + coll = get_collection(db, donation_id, tenant_no) + coll_dict = _collection_to_dict(coll) if coll else None + if coll_dict and coll_dict.get("salesUserId"): + user_ids.append(coll_dict["salesUserId"]) + if coll_dict: + region_id = str(coll_dict.get("regionId") or "") + if region_id: + r = db.query(BizCollectionRegion.region_name).filter( + BizCollectionRegion.id == region_id + ).first() + coll_dict["regionName"] = r.region_name if r else "" + hospital_id = str(coll_dict.get("hospitalId") or "") + if hospital_id: + h = db.query(BizCollectionHospital.hospital_name).filter( + BizCollectionHospital.id == hospital_id + ).first() + coll_dict["hospitalName"] = h.hospital_name if h else "" + out["collection"] = coll_dict + + # samples + transport + s_rows = list_samples(db, donation_id, tenant_no) + transport_rows = list_sample_transports(db, donation_id, tenant_no) + transport_by_sample = {t.sample_id: _transport_to_dict(t) for t in transport_rows} + samples_out: List[Dict[str, Any]] = [] + for s in s_rows: + item = _sample_to_dict(s) + item["transport"] = transport_by_sample.get(s.id) + samples_out.append(item) + out["samples"] = samples_out + + # user name map + user_name_map = _user_nick_name_map(db, user_ids) + out["donation"]["collectorUserName"] = user_name_map.get( + main.collector_user_id or "", "" + ) + if coll_dict and coll_dict.get("salesUserId"): + coll_dict["salesUserName"] = user_name_map.get( + coll_dict["salesUserId"], "" + ) + + return out + + +# --------------------------------------------------------------------------- +# 3. create_donation +# --------------------------------------------------------------------------- + + +def create_donation( + db: Session, + *, + data: dict, + tenant_no: str, + user_id: Optional[str] = None, +) -> BizResearchDonation: + """创建科研捐赠单(含 party / collection / samples + transports)。""" + raw = _normalize_fields(data) + + # 验证知情同意书 + consent = (raw.get("consent_form_urls") or "").strip() + if not consent: + raise ValueError("知情同意书不能为空") + + donation_no = (raw.get("donation_no") or "").strip() + if not donation_no: + raise ValueError("捐赠编号不能为空") + + # 检查编号唯一 + dup = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.donation_no == donation_no, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if dup: + raise ValueError("捐赠编号已存在") + + now = datetime.now() + obj = BizResearchDonation( + id=generate_uuid(), + donation_no=donation_no, + donation_status=(raw.get("donation_status") or "dsx").strip(), + donation_date=(raw.get("donation_date") or "").strip() or None, + source_type=(raw.get("source_type") or "direct").strip(), + source_protocol_id=(raw.get("source_protocol_id") or "").strip() or None, + consent_form_urls=consent or None, + attachment_urls=(raw.get("attachment_urls") or "").strip() or None, + collector_user_id=(raw.get("collector_user_id") or "").strip() or None, + tenant_no=tenant_no, + order_num=0, + remark=(raw.get("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() + + donation_id = obj.id + + # party + party_raw = _normalize_fields(raw.get("party") or {}) + _create_party(db, donation_id, tenant_no, user_id, party_raw, now) + + # collection + coll_raw = _normalize_fields(raw.get("collection") or {}) + _create_collection(db, donation_id, tenant_no, user_id, coll_raw, now) + + # samples + transports + samples_raw = raw.get("samples") or [] + _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now) + + db.commit() + db.refresh(obj) + return obj + + +# --------------------------------------------------------------------------- +# 4. update_donation +# --------------------------------------------------------------------------- + + +def update_donation( + db: Session, + donation_id: str, + *, + data: dict, + tenant_no: str, + user_id: Optional[str] = None, +) -> BizResearchDonation: + """更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。""" + obj = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == donation_id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if not obj: + raise ValueError("捐赠单不存在或无权操作") + + raw = _normalize_fields(data) + now = datetime.now() + + # 主表字段 + _MAIN_STR_FIELDS = [ + "donation_status", "donation_date", "source_type", + "source_protocol_id", "consent_form_urls", "attachment_urls", + "collector_user_id", "effective_desc", "expire_desc", + "void_desc", "to_storage_desc", "remark", + ] + _MAIN_DT_FIELDS = [ + "effective_time", "expire_time", "void_time", "to_storage_time", + ] + for k in _MAIN_STR_FIELDS: + if k in raw: + setattr(obj, k, (raw[k] or "").strip() or None) + for k in _MAIN_DT_FIELDS: + if k in raw: + val = raw[k] + if isinstance(val, str): + val = parse_datetime(val) + setattr(obj, k, val) + + obj.update_by = user_id + obj.update_time = now + db.add(obj) + db.flush() + + # party upsert: 软删旧 → 创建新 + if "party" in raw: + _soft_delete_party(db, donation_id, tenant_no, user_id) + party_raw = _normalize_fields(raw["party"] or {}) + _create_party(db, donation_id, tenant_no, user_id, party_raw, now) + + # collection upsert + if "collection" in raw: + _soft_delete_collection(db, donation_id, tenant_no, user_id) + coll_raw = _normalize_fields(raw["collection"] or {}) + _create_collection(db, donation_id, tenant_no, user_id, coll_raw, now) + + # samples upsert + if "samples" in raw: + _soft_delete_samples(db, donation_id, tenant_no, user_id) + _soft_delete_transports(db, donation_id, tenant_no, user_id) + _create_samples(db, donation_id, tenant_no, user_id, raw["samples"] or [], now) + + db.commit() + db.refresh(obj) + return obj + + +# --------------------------------------------------------------------------- +# 5. delete_donation / batch_delete_donations +# --------------------------------------------------------------------------- + + +def delete_donation( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str] = None, +) -> None: + """软删除捐赠单 + 所有子资源。""" + obj = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == donation_id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if not obj: + raise ValueError("捐赠单不存在或无权操作") + _soft_delete_all(db, donation_id, tenant_no, user_id) + db.commit() + + +def batch_delete_donations( + db: Session, + ids: List[str], + tenant_no: str, + user_id: Optional[str] = None, +) -> int: + """批量软删除捐赠单。""" + count = 0 + for did in (ids or []): + did = str(did).strip() + if not did: + continue + obj = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == did, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if not obj: + continue + _soft_delete_all(db, did, tenant_no, user_id) + count += 1 + db.commit() + return count + + +# --------------------------------------------------------------------------- +# 6. list_audits +# --------------------------------------------------------------------------- + + +def list_audits( + db: Session, + *, + tenant_no: str, + page: int = 1, + page_size: int = 10, + keyword: Optional[str] = None, + audit_status: Optional[str] = None, +) -> Dict[str, Any]: + """分页查询审核记录。""" + q_base = ( + db.query(BizResearchDonationAudit, BizResearchDonation.donation_no) + .join( + BizResearchDonation, + and_( + BizResearchDonationAudit.research_donation_id == BizResearchDonation.id, + BizResearchDonation.del_flag == 0, + ), + ) + .filter(BizResearchDonationAudit.del_flag == 0) + ) + if tenant_no: + q_base = q_base.filter(BizResearchDonationAudit.tenant_no == tenant_no) + if audit_status: + q_base = q_base.filter(BizResearchDonationAudit.audit_status == audit_status.strip()) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q_base = q_base.filter( + or_( + BizResearchDonation.donation_no.like(like), + BizResearchDonationAudit.audit_remark.like(like), + ) + ) + + total = ( + q_base.order_by(None) + .with_entities(func.count(BizResearchDonationAudit.id)) + .scalar() + ) or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q_base.order_by(BizResearchDonationAudit.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + auditor_ids = list({a.auditor_user_id for a, _ in rows if a.auditor_user_id}) + auditor_name_map = _user_nick_name_map(db, auditor_ids) + + result = [ + _audit_to_dict( + a, + donation_no=pn or "", + auditor_user_name=auditor_name_map.get(a.auditor_user_id or "", ""), + ) + for a, pn in rows + ] + + return { + "list": result, + "total": int(total), + "page": page, + "pageSize": page_size, + "totalPages": total_pages(int(total), page_size), + } + + +# --------------------------------------------------------------------------- +# 7. submit_audit +# --------------------------------------------------------------------------- + + +def submit_audit( + db: Session, + donation_id: str, + *, + audit_status: str, + audit_remark: Optional[str] = None, + user_id: Optional[str] = None, + tenant_no: str = "", +) -> BizResearchDonationAudit: + """提交审核。shtg → 捐赠状态变为 sxz;shth → 状态不变。""" + # 校验捐赠单存在 + main = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == donation_id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if not main: + raise ValueError("捐赠单不存在") + + st = (audit_status or "").strip() + if st not in ("shtg", "shth"): + raise ValueError("审核状态无效") + + now = datetime.now() + obj = BizResearchDonationAudit( + id=generate_uuid(), + research_donation_id=donation_id, + auditor_user_id=user_id, + audit_remark=(audit_remark or "").strip() or None, + audit_status=st, + tenant_no=tenant_no, + order_num=0, + remark=None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + + # 审核通过 → 变更状态为 sxz + if st == "shtg": + main.donation_status = "sxz" + main.effective_time = now + main.update_by = user_id + main.update_time = now + db.add(main) + + db.commit() + db.refresh(obj) + return obj + + +# --------------------------------------------------------------------------- +# 8. change_status +# --------------------------------------------------------------------------- + +# 合法状态转换: {当前状态: {可转状态列表}} +_VALID_TRANSITIONS: Dict[str, List[str]] = { + "dsx": ["sxz", "yzf"], + "sxz": ["ygq", "yzf", "zckc"], +} + + +def change_status( + db: Session, + donation_id: str, + *, + new_status: str, + desc: Optional[str] = None, + time_val: Optional[Any] = None, + user_id: Optional[str] = None, + tenant_no: str = "", +) -> BizResearchDonation: + """变更捐赠单状态(含合法性校验)。""" + obj = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == donation_id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if not obj: + raise ValueError("捐赠单不存在") + + current = obj.donation_status or "" + ns = (new_status or "").strip() + allowed = _VALID_TRANSITIONS.get(current, []) + if ns not in allowed: + raise ValueError(f"状态 {current} 不可转为 {ns}") + + now = datetime.now() + if isinstance(time_val, str): + time_val = parse_datetime(time_val) + effective_time = time_val or now + + obj.donation_status = ns + obj.update_by = user_id + obj.update_time = now + + # 更新对应的时间/描述字段 + if ns == "sxz": + obj.effective_time = effective_time + obj.effective_desc = (desc or "").strip() or None + elif ns == "yzf": + obj.void_time = effective_time + obj.void_desc = (desc or "").strip() or None + elif ns == "ygq": + obj.expire_time = effective_time + obj.expire_desc = (desc or "").strip() or None + elif ns == "zckc": + obj.to_storage_time = effective_time + obj.to_storage_desc = (desc or "").strip() or None + + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +# --------------------------------------------------------------------------- +# 9. convert_from_storage +# --------------------------------------------------------------------------- + + +def convert_from_storage( + db: Session, + protocol_id: str, + *, + tenant_no: str, + user_id: Optional[str] = None, +) -> BizResearchDonation: + """从细胞存储协议单转换创建科研捐赠单。""" + # 读取源协议 + proto = ( + db.query(BizCollectionProtocol) + .filter( + and_( + BizCollectionProtocol.id == protocol_id, + BizCollectionProtocol.tenant_no == tenant_no, + BizCollectionProtocol.del_flag == 0, + ) + ) + .first() + ) + if not proto: + raise ValueError("源协议单不存在") + + # 读取源子资源 + src_party = ( + db.query(BizProtocolPartyCustomer) + .filter( + and_( + BizProtocolPartyCustomer.collection_protocol_id == protocol_id, + BizProtocolPartyCustomer.tenant_no == tenant_no, + BizProtocolPartyCustomer.del_flag == 0, + ) + ) + .first() + ) + src_coll = ( + db.query(BizProtocolCollectionInfo) + .filter( + and_( + BizProtocolCollectionInfo.collection_protocol_id == protocol_id, + BizProtocolCollectionInfo.tenant_no == tenant_no, + BizProtocolCollectionInfo.del_flag == 0, + ) + ) + .first() + ) + src_samples = ( + db.query(BizProtocolSample) + .filter( + and_( + BizProtocolSample.collection_protocol_id == protocol_id, + BizProtocolSample.tenant_no == tenant_no, + BizProtocolSample.del_flag == 0, + ) + ) + .all() + ) + src_transports = ( + db.query(BizProtocolSampleTransport) + .filter( + and_( + BizProtocolSampleTransport.collection_protocol_id == protocol_id, + BizProtocolSampleTransport.tenant_no == tenant_no, + BizProtocolSampleTransport.del_flag == 0, + ) + ) + .all() + ) + transport_by_sample = {t.sample_id: t for t in src_transports} + + now = datetime.now() + donation_no = f"KY{now.strftime('%Y%m%d%H%M%S')}" + generate_uuid()[:6] + + # 创建主记录 + donation = BizResearchDonation( + id=generate_uuid(), + donation_no=donation_no, + donation_status="dsx", + donation_date=now.strftime("%Y%m%d"), + source_type="from_storage", + source_protocol_id=protocol_id, + consent_form_urls=proto.protocol_attachments, + collector_user_id=proto.collector_user_id, + tenant_no=tenant_no, + order_num=0, + remark=f"由细胞存储协议单 {proto.protocol_no} 转入", + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(donation) + db.flush() + + did = donation.id + + # 创建 party + if src_party: + party = BizResearchDonationParty( + id=generate_uuid(), + research_donation_id=did, + customer_id=src_party.customer_id, + mother_name=src_party.mother_name, + mother_phone=src_party.mother_phone, + mother_id_card=src_party.mother_id_card, + mother_birth_ym=src_party.mother_birth_ym, + mother_ethnic=src_party.mother_ethnic, + mother_address=src_party.mother_address, + father_name=src_party.father_name, + father_phone=src_party.father_phone, + father_id_card=src_party.father_id_card, + newborn_name=src_party.newborn_name, + newborn_id_card=src_party.newborn_id_card, + newborn_delivery_time=src_party.newborn_delivery_time, + newborn_sex=src_party.newborn_sex, + delivery_mode=src_party.delivery_mode, + birth_order=src_party.birth_order, + newborn_weight_gram=src_party.newborn_weight_gram, + apgar_1min=src_party.apgar_1min, + apgar_5min=src_party.apgar_5min, + collector_user_id=src_party.collector_user_id, + 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(party) + + # 创建 collection + if src_coll: + coll = BizResearchDonationCollection( + id=generate_uuid(), + research_donation_id=did, + region_id=src_coll.region_id, + hospital_id=src_coll.hospital_id, + medical_staff=src_coll.medical_staff, + sales_user_id=src_coll.sales_user_id, + sample_collect_time=src_coll.sample_collect_time, + maternal_blood_collect_time=src_coll.maternal_blood_collect_time, + 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(coll) + + # 创建 samples + transports + for idx, ss in enumerate(src_samples): + new_sample = BizResearchDonationSample( + id=generate_uuid(), + research_donation_id=did, + sample_type_id=ss.sample_type_id, + sample_code_id=ss.sample_code_id, + sample_code=ss.sample_code, + collect_amount=ss.collect_amount, + unit_id=ss.unit_id, + storage_years=ss.storage_years, + sample_status=ss.sample_status, + storage_in_date=ss.storage_in_date, + detect_start_date=ss.detect_start_date, + void_date=ss.void_date, + storage_expire_date=ss.storage_expire_date, + tenant_no=tenant_no, + order_num=idx, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(new_sample) + db.flush() + + # 对应运输记录 + src_t = transport_by_sample.get(ss.id) + if src_t: + new_t = BizResearchDonationSampleTransport( + id=generate_uuid(), + research_donation_id=did, + sample_id=new_sample.id, + transport_type=src_t.transport_type, + express_company_id=src_t.express_company_id, + waybill_no=src_t.waybill_no, + dest_site_id=src_t.dest_site_id, + express_fee=src_t.express_fee, + eta_time=src_t.eta_time, + carrier_name=src_t.carrier_name, + carrier_phone=src_t.carrier_phone, + tenant_no=tenant_no, + order_num=idx, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(new_t) + + # 更新源协议状态为 zky(转科研) + proto.protocol_status = "zky" + proto.to_research_time = now + proto.to_research_desc = f"已转入科研捐赠 {donation_no}" + proto.update_by = user_id + proto.update_time = now + db.add(proto) + + db.commit() + db.refresh(donation) + return donation + + +# --------------------------------------------------------------------------- +# 10. convert_to_storage +# --------------------------------------------------------------------------- + + +def convert_to_storage( + db: Session, + donation_id: str, + *, + tenant_no: str, + user_id: Optional[str] = None, +) -> BizCollectionProtocol: + """从科研捐赠单转换创建细胞存储协议单。""" + # 读取源捐赠 + donation = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == donation_id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if not donation: + raise ValueError("捐赠单不存在") + + src_party = get_party(db, donation_id, tenant_no) + src_coll = get_collection(db, donation_id, tenant_no) + src_samples = list_samples(db, donation_id, tenant_no) + src_transports = list_sample_transports(db, donation_id, tenant_no) + transport_by_sample = {t.sample_id: t for t in src_transports} + + now = datetime.now() + protocol_no = f"XC{now.strftime('%Y%m%d%H%M%S')}" + generate_uuid()[:6] + + # 创建协议单 + proto = BizCollectionProtocol( + id=generate_uuid(), + protocol_no=protocol_no, + protocol_status="dsx", + register_date=now.strftime("%Y%m%d"), + collector_user_id=donation.collector_user_id, + tenant_no=tenant_no, + order_num=0, + remark=f"由科研捐赠单 {donation.donation_no} 转入", + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(proto) + db.flush() + + pid = proto.id + + # 创建 party + if src_party: + party = BizProtocolPartyCustomer( + id=generate_uuid(), + collection_protocol_id=pid, + customer_id=src_party.customer_id, + mother_name=src_party.mother_name, + mother_phone=src_party.mother_phone, + mother_id_card=src_party.mother_id_card, + mother_birth_ym=src_party.mother_birth_ym, + mother_ethnic=src_party.mother_ethnic, + mother_address=src_party.mother_address, + father_name=src_party.father_name, + father_phone=src_party.father_phone, + father_id_card=src_party.father_id_card, + newborn_name=src_party.newborn_name, + newborn_id_card=src_party.newborn_id_card, + newborn_delivery_time=src_party.newborn_delivery_time, + newborn_sex=src_party.newborn_sex, + delivery_mode=src_party.delivery_mode, + birth_order=src_party.birth_order, + newborn_weight_gram=src_party.newborn_weight_gram, + apgar_1min=src_party.apgar_1min, + apgar_5min=src_party.apgar_5min, + collector_user_id=src_party.collector_user_id, + 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(party) + + # 创建 collection + if src_coll: + coll = BizProtocolCollectionInfo( + id=generate_uuid(), + collection_protocol_id=pid, + region_id=src_coll.region_id, + hospital_id=src_coll.hospital_id, + medical_staff=src_coll.medical_staff, + sales_user_id=src_coll.sales_user_id, + sample_collect_time=src_coll.sample_collect_time, + maternal_blood_collect_time=src_coll.maternal_blood_collect_time, + 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(coll) + + # 创建 samples + transports(无 price 字段 → null) + for idx, ss in enumerate(src_samples): + new_sample = BizProtocolSample( + id=generate_uuid(), + collection_protocol_id=pid, + sample_type_id=ss.sample_type_id, + sample_code_id=ss.sample_code_id, + sample_code=ss.sample_code, + collect_amount=ss.collect_amount, + unit_id=ss.unit_id, + price=None, + storage_years=ss.storage_years, + sample_status=ss.sample_status, + storage_in_date=ss.storage_in_date, + detect_start_date=ss.detect_start_date, + void_date=ss.void_date, + storage_expire_date=ss.storage_expire_date, + tenant_no=tenant_no, + order_num=idx, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(new_sample) + db.flush() + + src_t = transport_by_sample.get(ss.id) + if src_t: + new_t = BizProtocolSampleTransport( + id=generate_uuid(), + collection_protocol_id=pid, + sample_id=new_sample.id, + transport_type=src_t.transport_type, + express_company_id=src_t.express_company_id, + waybill_no=src_t.waybill_no, + dest_site_id=src_t.dest_site_id, + express_fee=src_t.express_fee, + eta_time=src_t.eta_time, + carrier_name=src_t.carrier_name, + carrier_phone=src_t.carrier_phone, + tenant_no=tenant_no, + order_num=idx, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(new_t) + + # 更新捐赠单状态为 zckc + donation.donation_status = "zckc" + donation.to_storage_time = now + donation.to_storage_desc = f"已转细胞存储 {protocol_no}" + donation.update_by = user_id + donation.update_time = now + db.add(donation) + + db.commit() + db.refresh(proto) + return proto + + +# --------------------------------------------------------------------------- +# Internal: sub-resource create helpers +# --------------------------------------------------------------------------- + +_PARTY_STR_FIELDS = [ + "customer_id", "mother_name", "mother_phone", "mother_id_card", + "mother_birth_ym", "mother_ethnic", "mother_address", + "father_name", "father_phone", "father_id_card", + "newborn_name", "newborn_id_card", "newborn_sex", + "delivery_mode", "collector_user_id", +] +_PARTY_INT_FIELDS = ["birth_order", "newborn_weight_gram"] +_PARTY_DEC_FIELDS = ["apgar_1min", "apgar_5min"] +_PARTY_DT_FIELDS = ["newborn_delivery_time"] + + +def _create_party( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], + raw: dict, + now: datetime, +) -> BizResearchDonationParty: + obj = BizResearchDonationParty( + id=generate_uuid(), + research_donation_id=donation_id, + tenant_no=tenant_no, + order_num=0, + remark=(raw.get("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, + ) + for k in _PARTY_STR_FIELDS: + if k in raw: + setattr(obj, k, (raw[k] or "").strip() or None) + for k in _PARTY_INT_FIELDS: + if k in raw: + setattr(obj, k, raw[k]) + for k in _PARTY_DEC_FIELDS: + if k in raw: + setattr(obj, k, _optional_decimal(raw[k])) + for k in _PARTY_DT_FIELDS: + if k in raw: + val = raw[k] + if isinstance(val, str): + val = parse_datetime(val) + setattr(obj, k, val) + db.add(obj) + db.flush() + return obj + + +_COLL_STR_FIELDS = ["region_id", "hospital_id", "medical_staff", "sales_user_id"] +_COLL_DT_FIELDS = ["sample_collect_time", "maternal_blood_collect_time"] + + +def _create_collection( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], + raw: dict, + now: datetime, +) -> BizResearchDonationCollection: + obj = BizResearchDonationCollection( + id=generate_uuid(), + research_donation_id=donation_id, + tenant_no=tenant_no, + order_num=0, + remark=(raw.get("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, + ) + for k in _COLL_STR_FIELDS: + if k in raw: + setattr(obj, k, (raw[k] or "").strip() or None) + for k in _COLL_DT_FIELDS: + if k in raw: + val = raw[k] + if isinstance(val, str): + val = parse_datetime(val) + setattr(obj, k, val) + db.add(obj) + db.flush() + return obj + + +_SAMPLE_STR_FIELDS = [ + "sample_type_id", "sample_code_id", "sample_code", "unit_id", + "sample_status", "storage_in_date", "detect_start_date", + "void_date", "storage_expire_date", +] +_SAMPLE_DEC_FIELDS = ["collect_amount"] +_SAMPLE_INT_FIELDS = ["storage_years"] + + +def _create_samples( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], + samples: list, + now: datetime, +) -> List[BizResearchDonationSample]: + result = [] + for idx, raw_item in enumerate(samples or []): + raw = _normalize_fields(raw_item) + obj = BizResearchDonationSample( + id=generate_uuid(), + research_donation_id=donation_id, + tenant_no=tenant_no, + order_num=idx, + remark=(raw.get("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, + ) + for k in _SAMPLE_STR_FIELDS: + if k == "sample_status": + continue + if k in raw: + setattr(obj, k, (raw[k] or "").strip() or None) + if "sample_status" in raw: + status_val = raw.get("sample_status") + obj.sample_status = ( + str(status_val).strip() + if status_val is not None and str(status_val).strip() + else None + ) + for k in _SAMPLE_DEC_FIELDS: + if k in raw and raw[k] is not None: + setattr(obj, k, Decimal(str(raw[k]))) + for k in _SAMPLE_INT_FIELDS: + if k in raw: + setattr(obj, k, raw[k]) + db.add(obj) + db.flush() + result.append(obj) + + # transport + transport_raw = raw.get("transport") + if isinstance(transport_raw, dict): + _create_transport(db, donation_id, obj.id, tenant_no, user_id, transport_raw, idx, now) + + return result + + +def _create_transport( + db: Session, + donation_id: str, + sample_id: str, + tenant_no: str, + user_id: Optional[str], + raw: dict, + order_num: int, + now: datetime, +) -> Optional[BizResearchDonationSampleTransport]: + tnorm = _normalize_fields(raw) + transport_type = (tnorm.get("transport_type") or "").strip() + if not transport_type: + return None + + eta_time = tnorm.get("eta_time") + if isinstance(eta_time, str): + eta_time = parse_datetime(eta_time) + + express_fee = tnorm.get("express_fee") + if express_fee is not None and express_fee != "": + express_fee = Decimal(str(express_fee)) + else: + express_fee = None + + obj = BizResearchDonationSampleTransport( + id=generate_uuid(), + research_donation_id=donation_id, + sample_id=sample_id, + transport_type=transport_type, + express_company_id=(tnorm.get("express_company_id") or "").strip() or None, + waybill_no=(tnorm.get("waybill_no") or "").strip() or None, + dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None, + express_fee=express_fee, + eta_time=eta_time, + carrier_name=(tnorm.get("carrier_name") or "").strip() or None, + carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None, + tenant_no=tenant_no, + order_num=order_num, + remark=(tnorm.get("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() + return obj + + +# --------------------------------------------------------------------------- +# Internal: soft delete helpers +# --------------------------------------------------------------------------- + + +def _soft_delete_party( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], +) -> None: + rows = ( + db.query(BizResearchDonationParty) + .filter( + and_( + BizResearchDonationParty.research_donation_id == donation_id, + BizResearchDonationParty.tenant_no == tenant_no, + BizResearchDonationParty.del_flag == 0, + ) + ) + .all() + ) + for r in rows: + r.del_flag = 1 + r.update_by = user_id + r.update_time = datetime.now() + db.add(r) + if rows: + db.flush() + + +def _soft_delete_collection( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], +) -> None: + rows = ( + db.query(BizResearchDonationCollection) + .filter( + and_( + BizResearchDonationCollection.research_donation_id == donation_id, + BizResearchDonationCollection.tenant_no == tenant_no, + BizResearchDonationCollection.del_flag == 0, + ) + ) + .all() + ) + for r in rows: + r.del_flag = 1 + r.update_by = user_id + r.update_time = datetime.now() + db.add(r) + if rows: + db.flush() + + +def _soft_delete_samples( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], +) -> None: + rows = ( + db.query(BizResearchDonationSample) + .filter( + and_( + BizResearchDonationSample.research_donation_id == donation_id, + BizResearchDonationSample.tenant_no == tenant_no, + BizResearchDonationSample.del_flag == 0, + ) + ) + .all() + ) + for r in rows: + r.del_flag = 1 + r.update_by = user_id + r.update_time = datetime.now() + db.add(r) + if rows: + db.flush() + + +def _soft_delete_transports( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], +) -> None: + rows = ( + db.query(BizResearchDonationSampleTransport) + .filter( + and_( + BizResearchDonationSampleTransport.research_donation_id == donation_id, + BizResearchDonationSampleTransport.tenant_no == tenant_no, + BizResearchDonationSampleTransport.del_flag == 0, + ) + ) + .all() + ) + for r in rows: + r.del_flag = 1 + r.update_by = user_id + r.update_time = datetime.now() + db.add(r) + if rows: + db.flush() + + +def _soft_delete_all( + db: Session, + donation_id: str, + tenant_no: str, + user_id: Optional[str], +) -> None: + """软删除主记录 + 所有子资源。""" + now = datetime.now() + # 主记录 + main = ( + db.query(BizResearchDonation) + .filter( + and_( + BizResearchDonation.id == donation_id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + ) + ) + .first() + ) + if main: + main.del_flag = 1 + main.update_by = user_id + main.update_time = now + db.add(main) + + _soft_delete_party(db, donation_id, tenant_no, user_id) + _soft_delete_collection(db, donation_id, tenant_no, user_id) + _soft_delete_samples(db, donation_id, tenant_no, user_id) + _soft_delete_transports(db, donation_id, tenant_no, user_id) diff --git a/backend/app/crud/sop_document.py b/backend/app/crud/sop_document.py new file mode 100644 index 00000000..f40d4428 --- /dev/null +++ b/backend/app/crud/sop_document.py @@ -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))) diff --git a/backend/app/main.py b/backend/app/main.py index 978354cc..1798b3c4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9f91f16c..457ccf97 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/models.py b/backend/app/models/models.py index ec9f1c66..8fec0a02 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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 diff --git a/backend/app/models/research_donation.py b/backend/app/models/research_donation.py new file mode 100644 index 00000000..e8be91bf --- /dev/null +++ b/backend/app/models/research_donation.py @@ -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="删除标记") diff --git a/docs/sql/checkup_institution_package.sql b/docs/sql/checkup_institution_package.sql new file mode 100644 index 00000000..5f9ec891 --- /dev/null +++ b/docs/sql/checkup_institution_package.sql @@ -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='体检机构项目套餐明细'; diff --git a/docs/sql/checkup_institution_project_status.sql b/docs/sql/checkup_institution_project_status.sql new file mode 100644 index 00000000..5c014f6b --- /dev/null +++ b/docs/sql/checkup_institution_project_status.sql @@ -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`; diff --git a/docs/sql/production.sql b/docs/sql/production.sql new file mode 100644 index 00000000..d613e007 --- /dev/null +++ b/docs/sql/production.sql @@ -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); diff --git a/docs/sql/research_donation_tables.sql b/docs/sql/research_donation_tables.sql new file mode 100644 index 00000000..902cd4fd --- /dev/null +++ b/docs/sql/research_donation_tables.sql @@ -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); diff --git a/docs/sql/sys_menu_pilot_service.sql b/docs/sql/sys_menu_pilot_service.sql new file mode 100644 index 00000000..65f06ae5 --- /dev/null +++ b/docs/sql/sys_menu_pilot_service.sql @@ -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); diff --git a/docs/sql/zkxw.sql b/docs/sql/zkxw.sql index b173464d..e5d92a5a 100644 --- a/docs/sql/zkxw.sql +++ b/docs/sql/zkxw.sql @@ -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 -- ---------------------------- diff --git a/docs/superpowers/plans/2026-05-17-pilot-service-management.md b/docs/superpowers/plans/2026-05-17-pilot-service-management.md new file mode 100644 index 00000000..7007f1a3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-pilot-service-management.md @@ -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" +``` diff --git a/docs/superpowers/plans/2026-05-17-production-management.md b/docs/superpowers/plans/2026-05-17-production-management.md new file mode 100644 index 00000000..6e8a5239 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-production-management.md @@ -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({ url: '/web/admin/consumables/all' }) +} +export function fetchCreateConsumable(data: Record) { + return request.post({ url: '/web/admin/consumables', params: data }) +} +export function fetchUpdateConsumable(id: string, data: Record) { + 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) diff --git a/docs/superpowers/plans/2026-05-17-research-donation-module.md b/docs/superpowers/plans/2026-05-17-research-donation-module.md new file mode 100644 index 00000000..ed0e336f --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-research-donation-module.md @@ -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 = { + list: T[] + total: number + page: number + pageSize: number + totalPages: number +} + +export type ResearchDonationRow = Record + +// 列表 +export function fetchResearchDonationList(params?: { + page?: number + pageSize?: number + keyword?: string + donationStatus?: string +}) { + return request.get>({ + url: '/web/admin/research-donations', + params + }) +} + +// 详情 +export function fetchResearchDonationDetail(id: string) { + return request.get<{ + donation: Record + party: Record | null + collection: Record | null + samples: Array> | null + attachments?: Array> + }>({ + url: `/web/admin/research-donations/${id}` + }) +} + +// 创建 +export function fetchCreateResearchDonation(data: Record) { + return request.post({ + url: '/web/admin/research-donations', + data, + showSuccessMessage: true + }) +} + +// 更新 +export function fetchUpdateResearchDonation(id: string, data: Record) { + 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>>({ + 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 只读展示 | +| 删除(单个/批量) | 软删除 | +| 审核流程 | 审核页面操作 | +| 状态变更 | 生效/过期/作废 | +| 转细胞存储 | 捐赠 → 细胞存储协议 | +| 细胞存储转科研 | 细胞存储 → 科研捐赠 | diff --git a/docs/superpowers/plans/2026-05-18-checkup-project-package.md b/docs/superpowers/plans/2026-05-18-checkup-project-package.md new file mode 100644 index 00000000..de566534 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-checkup-project-package.md @@ -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 +) { + return request.post({ + url: `/web/admin/checkup-institutions/${institutionId}/packages`, + params: data + }) +} + +export function fetchUpdateCheckupInstitutionPackage( + packageId: string, + data: Record +) { + 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` 时 `源项目已删除`)、项目费用(可编辑 `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 + + + + + + + + +``` + +- [ ] **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。 diff --git a/docs/superpowers/specs/2026-05-17-pilot-service-management-design.md b/docs/superpowers/specs/2026-05-17-pilot-service-management-design.md new file mode 100644 index 00000000..f703f27e --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-pilot-service-management-design.md @@ -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`,后续按需添加 diff --git a/docs/superpowers/specs/2026-05-17-production-management-design.md b/docs/superpowers/specs/2026-05-17-production-management-design.md new file mode 100644 index 00000000..9b31aa79 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-production-management-design.md @@ -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批一起执行 diff --git a/docs/superpowers/specs/2026-05-17-research-donation-module-design.md b/docs/superpowers/specs/2026-05-17-research-donation-module-design.md new file mode 100644 index 00000000..327fdc1b --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-research-donation-module-design.md @@ -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. 联调测试 diff --git a/docs/superpowers/specs/2026-05-18-checkup-project-package-design.md b/docs/superpowers/specs/2026-05-18-checkup-project-package-design.md new file mode 100644 index 00000000..30edc8f1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-checkup-project-package-design.md @@ -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 补充) + +| 议题 | 结论 | +|------|------| +| 套餐编码 | **必填**;机构内未删除套餐编码唯一 | +| 删除被套餐引用的机构项目 | **允许软删除**;套餐明细**不自动移除**;靠快照保证历史展示;业务带入时跳过无效源项目 | diff --git a/web-admin/src/App.vue b/web-admin/src/App.vue index 2b76ca90..fae9f0b4 100644 --- a/web-admin/src/App.vue +++ b/web-admin/src/App.vue @@ -80,7 +80,7 @@ const panelTitleClassList = panelTitleIconByText.map(([, cls]) => cls) const panelTitleBarClass = 'title-bar-heading' const panelTitleBarTextRules: RegExp[] = [ - /(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表)/ + /(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表|类型列表|规格列表|价格列表|体系列表|渠道列表|机构列表|耗材列表|试剂列表|SOP列表|工艺列表|耗材包列表)/ ] const refreshPanelTitleIcons = () => { diff --git a/web-admin/src/api/admin.ts b/web-admin/src/api/admin.ts index d7f8ab01..a0238059 100644 --- a/web-admin/src/api/admin.ts +++ b/web-admin/src/api/admin.ts @@ -1368,3 +1368,362 @@ export function fetchUpdateExhibitionAppointment(id: string, data: Record) { + return request.post({ url: '/web/admin/cell-product-types', params: data }) +} + +export function fetchUpdateCellProductType(id: string, data: Record) { + 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) { + return request.post({ url: '/web/admin/cell-product-specs', params: data }) +} + +export function fetchUpdateCellProductSpec(id: string, data: Record) { + 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) { + return request.post({ url: '/web/admin/cell-product-prices', params: data }) +} + +export function fetchUpdateCellProductPrice(id: string, data: Record) { + 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) { + return request.post({ url: '/web/admin/project-price-systems', params: data }) +} + +export function fetchUpdateProjectSystem(id: string, data: Record) { + 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) { + return request.post({ url: `/web/admin/project-price-systems/${systemId}/items`, params: data }) +} + +export function fetchUpdateProjectSystemItem(id: string, data: Record) { + 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) { + return request.post({ url: '/web/admin/channels', params: data }) +} + +export function fetchUpdateChannel(id: string, data: Record) { + 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) { + return request.post({ url: `/web/admin/channels/${channelId}/invoices`, params: data }) +} + +export function fetchUpdateChannelInvoice(id: string, data: Record) { + 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) { + return request.post({ url: `/web/admin/channels/${channelId}/mail-addresses`, params: data }) +} + +export function fetchUpdateChannelMailAddress(id: string, data: Record) { + 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) { + return request.post({ url: `/web/admin/channels/${channelId}/agreements`, params: data }) +} + +export function fetchUpdateChannelAgreement(id: string, data: Record) { + 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) { + return request.post({ + url: `/web/admin/channel-agreements/${agreementId}/product-prices`, + params: data + }) +} + +export function fetchUpdateChannelAgreementPrice(id: string, data: Record) { + 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) { + return request.post({ url: '/web/admin/checkup-institutions', params: data }) +} + +export function fetchUpdateCheckupInstitution(id: string, data: Record) { + 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 +) { + return request.post({ + url: `/web/admin/checkup-institutions/${institutionId}/invoices`, + params: data + }) +} + +export function fetchUpdateCheckupInstitutionInvoice(id: string, data: Record) { + 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 +) { + return request.post({ + url: `/web/admin/checkup-institutions/${institutionId}/projects`, + params: data + }) +} + +export function fetchUpdateCheckupInstitutionProject(id: string, data: Record) { + 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 +) { + return request.post({ + url: `/web/admin/checkup-institutions/${institutionId}/packages`, + params: data + }) +} + +export function fetchUpdateCheckupInstitutionPackage( + packageId: string, + data: Record +) { + 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}` }) +} diff --git a/web-admin/src/api/production.ts b/web-admin/src/api/production.ts new file mode 100644 index 00000000..377e3867 --- /dev/null +++ b/web-admin/src/api/production.ts @@ -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({ url: '/web/admin/consumables/all' }) +} + +export function fetchCreateConsumable(data: Record) { + return request.post({ url: '/web/admin/consumables', params: data }) +} + +export function fetchUpdateConsumable(id: string, data: Record) { + 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({ url: '/web/admin/reagents/all' }) +} + +export function fetchCreateReagent(data: Record) { + return request.post({ url: '/web/admin/reagents', params: data }) +} + +export function fetchUpdateReagent(id: string, data: Record) { + 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({ url: '/web/admin/sop-documents/all' }) +} + +export function fetchCreateSop(data: Record) { + return request.post({ url: '/web/admin/sop-documents', params: data }) +} + +export function fetchUpdateSop(id: string, data: Record) { + 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({ url: '/web/admin/process-categories/all' }) +} + +export function fetchCreateProcessCategory(data: Record) { + return request.post({ url: '/web/admin/process-categories', params: data }) +} + +export function fetchUpdateProcessCategory(id: string, data: Record) { + 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({ url: '/web/admin/output-types/all' }) +} + +export function fetchCreateOutputType(data: Record) { + return request.post({ url: '/web/admin/output-types', params: data }) +} + +export function fetchUpdateOutputType(id: string, data: Record) { + 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({ url: '/web/admin/consumable-packs/all' }) +} + +export function fetchCreateConsumablePack(data: Record) { + return request.post({ url: '/web/admin/consumable-packs', params: data }) +} + +export function fetchUpdateConsumablePack(id: string, data: Record) { + 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({ 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({ url: '/web/admin/processes/all' }) +} + +export function fetchCreateProcess(data: Record) { + return request.post({ url: '/web/admin/processes', params: data }) +} + +export function fetchUpdateProcess(id: string, data: Record) { + 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({ url: `/web/admin/processes/${id}` }) +} diff --git a/web-admin/src/api/research-donation.ts b/web-admin/src/api/research-donation.ts new file mode 100644 index 00000000..657b70f8 --- /dev/null +++ b/web-admin/src/api/research-donation.ts @@ -0,0 +1,131 @@ +import request from '@/utils/http' + +type PageList = { + list: T[] + total: number + page: number + pageSize: number + totalPages: number +} + +export type ResearchDonationRow = Record + +// 列表 +export function fetchResearchDonationList(params?: { + page?: number + pageSize?: number + keyword?: string + donationStatus?: string +}) { + return request.get>({ + url: '/web/admin/research-donations', + params + }) +} + +// 详情 +export function fetchResearchDonationDetail(id: string) { + return request.get<{ + donation: Record + party: Record | null + collection: Record | null + samples: Array> | null + attachments?: Array> + }>({ + url: `/web/admin/research-donations/${id}` + }) +} + +// 创建 +export function fetchCreateResearchDonation(data: Record) { + return request.post({ + url: '/web/admin/research-donations', + data, + showSuccessMessage: true + }) +} + +// 更新 +export function fetchUpdateResearchDonation(id: string, data: Record) { + 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>>({ + 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 + }) +} diff --git a/web-admin/src/assets/styles/core/app.scss b/web-admin/src/assets/styles/core/app.scss index 885664eb..0ce1a178 100755 --- a/web-admin/src/assets/styles/core/app.scss +++ b/web-admin/src/assets/styles/core/app.scss @@ -589,7 +589,6 @@ body { } .detail-section { - border: 1px solid var(--mint-border); border-radius: var(--mint-radius); overflow: hidden; margin-bottom: 14px; diff --git a/web-admin/src/router/modules/sqxp.ts b/web-admin/src/router/modules/sqxp.ts index 753707bc..21798beb 100644 --- a/web-admin/src/router/modules/sqxp.ts +++ b/web-admin/src/router/modules/sqxp.ts @@ -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 } + } + ] } ] diff --git a/web-admin/src/types/api.d.ts b/web-admin/src/types/api.d.ts index 12c3aa94..8b977763 100644 --- a/web-admin/src/types/api.d.ts +++ b/web-admin/src/types/api.d.ts @@ -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' { diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue b/web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue index a63254d5..05fd0780 100644 --- a/web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue +++ b/web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue @@ -64,9 +64,10 @@ + + diff --git a/web-admin/src/views/pilot-service/cell-product-spec/index.vue b/web-admin/src/views/pilot-service/cell-product-spec/index.vue new file mode 100644 index 00000000..1e9fd0e0 --- /dev/null +++ b/web-admin/src/views/pilot-service/cell-product-spec/index.vue @@ -0,0 +1,327 @@ + + + + + + + diff --git a/web-admin/src/views/pilot-service/cell-product-type/index.vue b/web-admin/src/views/pilot-service/cell-product-type/index.vue new file mode 100644 index 00000000..f267d57b --- /dev/null +++ b/web-admin/src/views/pilot-service/cell-product-type/index.vue @@ -0,0 +1,327 @@ + + + + + + + diff --git a/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue b/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue new file mode 100644 index 00000000..04e8b3af --- /dev/null +++ b/web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue @@ -0,0 +1,815 @@ + + + + + + diff --git a/web-admin/src/views/pilot-service/channel/index.vue b/web-admin/src/views/pilot-service/channel/index.vue new file mode 100644 index 00000000..66b2a71d --- /dev/null +++ b/web-admin/src/views/pilot-service/channel/index.vue @@ -0,0 +1,2718 @@ + + + + + + + + + diff --git a/web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue b/web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue new file mode 100644 index 00000000..0df6aa29 --- /dev/null +++ b/web-admin/src/views/pilot-service/checkup-institution/CheckupPackagePanel.vue @@ -0,0 +1,461 @@ + + + + + + + diff --git a/web-admin/src/views/pilot-service/checkup-institution/index.vue b/web-admin/src/views/pilot-service/checkup-institution/index.vue new file mode 100644 index 00000000..a7f94bf4 --- /dev/null +++ b/web-admin/src/views/pilot-service/checkup-institution/index.vue @@ -0,0 +1,761 @@ + + + + + + + + + + diff --git a/web-admin/src/views/pilot-service/project-price-system/index.vue b/web-admin/src/views/pilot-service/project-price-system/index.vue new file mode 100644 index 00000000..41a5bd98 --- /dev/null +++ b/web-admin/src/views/pilot-service/project-price-system/index.vue @@ -0,0 +1,653 @@ + + + + + + + diff --git a/web-admin/src/views/production/consumable-pack/index.vue b/web-admin/src/views/production/consumable-pack/index.vue new file mode 100644 index 00000000..309593fb --- /dev/null +++ b/web-admin/src/views/production/consumable-pack/index.vue @@ -0,0 +1,537 @@ + + + + + + diff --git a/web-admin/src/views/production/consumable/index.vue b/web-admin/src/views/production/consumable/index.vue new file mode 100644 index 00000000..beddde78 --- /dev/null +++ b/web-admin/src/views/production/consumable/index.vue @@ -0,0 +1,358 @@ + + + + + + + diff --git a/web-admin/src/views/production/output-type/index.vue b/web-admin/src/views/production/output-type/index.vue new file mode 100644 index 00000000..48823b6d --- /dev/null +++ b/web-admin/src/views/production/output-type/index.vue @@ -0,0 +1,322 @@ + + + + + + + diff --git a/web-admin/src/views/production/process-category/index.vue b/web-admin/src/views/production/process-category/index.vue new file mode 100644 index 00000000..46321f96 --- /dev/null +++ b/web-admin/src/views/production/process-category/index.vue @@ -0,0 +1,322 @@ + + + + + + + diff --git a/web-admin/src/views/production/process/index.vue b/web-admin/src/views/production/process/index.vue new file mode 100644 index 00000000..b52f577a --- /dev/null +++ b/web-admin/src/views/production/process/index.vue @@ -0,0 +1,891 @@ + + + + + + diff --git a/web-admin/src/views/production/reagent/index.vue b/web-admin/src/views/production/reagent/index.vue new file mode 100644 index 00000000..663fde71 --- /dev/null +++ b/web-admin/src/views/production/reagent/index.vue @@ -0,0 +1,379 @@ + + + + + + + diff --git a/web-admin/src/views/production/sop/index.vue b/web-admin/src/views/production/sop/index.vue new file mode 100644 index 00000000..b4da670d --- /dev/null +++ b/web-admin/src/views/production/sop/index.vue @@ -0,0 +1,492 @@ + + + + + + + diff --git a/web-admin/src/views/research-donation/audit/index.vue b/web-admin/src/views/research-donation/audit/index.vue new file mode 100644 index 00000000..48af59b5 --- /dev/null +++ b/web-admin/src/views/research-donation/audit/index.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/web-admin/src/views/research-donation/list/DonationAuditDialog.vue b/web-admin/src/views/research-donation/list/DonationAuditDialog.vue new file mode 100644 index 00000000..059fa04d --- /dev/null +++ b/web-admin/src/views/research-donation/list/DonationAuditDialog.vue @@ -0,0 +1,133 @@ + + + + + + diff --git a/web-admin/src/views/research-donation/list/DonationFormDialog.vue b/web-admin/src/views/research-donation/list/DonationFormDialog.vue new file mode 100644 index 00000000..31b42aab --- /dev/null +++ b/web-admin/src/views/research-donation/list/DonationFormDialog.vue @@ -0,0 +1,680 @@ + + + + + + + diff --git a/web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue b/web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue new file mode 100644 index 00000000..16540822 --- /dev/null +++ b/web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue @@ -0,0 +1,228 @@ + + + + + + diff --git a/web-admin/src/views/research-donation/list/DonationViewDialog.vue b/web-admin/src/views/research-donation/list/DonationViewDialog.vue new file mode 100644 index 00000000..d0704b85 --- /dev/null +++ b/web-admin/src/views/research-donation/list/DonationViewDialog.vue @@ -0,0 +1,975 @@ + + + + + + diff --git a/web-admin/src/views/research-donation/list/index.vue b/web-admin/src/views/research-donation/list/index.vue new file mode 100644 index 00000000..b7896a1c --- /dev/null +++ b/web-admin/src/views/research-donation/list/index.vue @@ -0,0 +1,404 @@ + + + + +