提交
This commit is contained in:
1 parent
135dd550b6
commit
2a4767a014
76 files changed
+25160
-2306
No files matched your search
@@ -0,0 +1,623 @@
|
||||
# 检测参数模板 + 质检任务结果录入 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_qc_item_param`(参数模板表)和 `biz_qc_task_result`(任务结果值表)。参数模板挂在检测项目下,工单配置生成任务时自动携带参数快照,检测人员在任务中填写参数值后提交完成任务。
|
||||
|
||||
**Tech Stack:** MySQL 5.7 / SQLAlchemy / FastAPI / Pydantic v2 / Vue 3 / Element Plus / TypeScript
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
- `docs/sql/qc_item_param_and_task_result.sql` — DDL for 2 new tables
|
||||
- `backend/app/models/qc_param.py` — SQLAlchemy models for BizQcItemParam, BizQcTaskResult
|
||||
- `backend/app/crud/qc_item_param.py` — CRUD for item params
|
||||
- `backend/app/crud/qc_task_result.py` — CRUD for task results
|
||||
- `web-admin/src/api/qc-item-param.ts` — Frontend API for item params
|
||||
- `web-admin/src/api/qc-task-result.ts` — Frontend API for task results
|
||||
|
||||
### Modified files
|
||||
- `backend/app/models/models.py` — Import new models
|
||||
- `backend/app/api/qc.py` — New endpoints for param CRUD + task result submission
|
||||
- `backend/app/crud/qc_task.py` — on task creation, snapshot params; on complete, validate
|
||||
- `backend/app/api/qc.py` — Task complete endpoint accepts result values
|
||||
- `web-admin/src/api/qc-task.ts` — Update types, add result submission API
|
||||
- `web-admin/src/views/quality-inspection/lab-test-item/index.vue` — Add param config in dialog
|
||||
- `web-admin/src/views/quality-inspection/third-party-company/index.vue` — Add param config in item dialog
|
||||
- `web-admin/src/views/quality-inspection/qc-task/index.vue` — Replace simple "完成" with "填写结果" dialog
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create DDL for new tables
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/qc_item_param_and_task_result.sql`
|
||||
|
||||
- [ ] **Step 1: Write the DDL**
|
||||
|
||||
```sql
|
||||
-- 检测项目参数模板
|
||||
CREATE TABLE IF NOT EXISTS `biz_qc_item_param` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`item_source` varchar(16) NOT NULL COMMENT 'lab实验室 third_party第三方',
|
||||
`item_id` varchar(64) NOT NULL COMMENT '检测项目ID',
|
||||
`param_name` varchar(200) NOT NULL COMMENT '参数名称',
|
||||
`param_code` varchar(64) NOT NULL COMMENT '参数编码',
|
||||
`param_unit` varchar(32) DEFAULT NULL COMMENT '单位',
|
||||
`param_type` varchar(16) NOT NULL DEFAULT 'text' COMMENT 'number/text/select',
|
||||
`required_flag` char(1) NOT NULL DEFAULT '0' 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 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_qc_param_item` (`item_source`, `item_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='检测项目参数模板';
|
||||
|
||||
-- 质检任务检测结果
|
||||
CREATE TABLE IF NOT EXISTS `biz_qc_task_result` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`task_id` varchar(64) NOT NULL COMMENT '质检任务ID',
|
||||
`param_id` varchar(64) DEFAULT NULL COMMENT '参数模板ID',
|
||||
`param_name` varchar(200) NOT NULL COMMENT '参数名快照',
|
||||
`param_code` varchar(64) DEFAULT NULL COMMENT '参数编码快照',
|
||||
`param_unit` varchar(32) DEFAULT NULL COMMENT '单位快照',
|
||||
`param_type` varchar(16) NOT NULL DEFAULT 'text' COMMENT '类型快照',
|
||||
`param_value` varchar(500) DEFAULT NULL COMMENT '填写值',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' 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_qc_result_task` (`task_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='质检任务检测结果';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — SQLAlchemy models
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/qc_param.py`
|
||||
- Modify: `backend/app/models/models.py` — add import at bottom
|
||||
|
||||
- [ ] **Step 1: Create `backend/app/models/qc_param.py`**
|
||||
|
||||
```python
|
||||
"""检测参数模板 + 质检任务结果"""
|
||||
from app.models.base import Base
|
||||
from sqlalchemy import Column, DateTime, Integer, SmallInteger, String
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BizQcItemParam(Base):
|
||||
"""检测项目参数模板 biz_qc_item_param"""
|
||||
__tablename__ = "biz_qc_item_param"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
item_source = Column(String(16), nullable=False, comment="lab实验室 third_party第三方")
|
||||
item_id = Column(String(64), nullable=False, comment="检测项目ID")
|
||||
param_name = Column(String(200), nullable=False, comment="参数名称")
|
||||
param_code = Column(String(64), nullable=False, comment="参数编码")
|
||||
param_unit = Column(String(32), nullable=True, comment="单位")
|
||||
param_type = Column(String(16), nullable=False, default="text", comment="number/text/select")
|
||||
required_flag = Column(String(1), nullable=False, default="0", 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 BizQcTaskResult(Base):
|
||||
"""质检任务检测结果 biz_qc_task_result"""
|
||||
__tablename__ = "biz_qc_task_result"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
task_id = Column(String(64), nullable=False, comment="质检任务ID")
|
||||
param_id = Column(String(64), nullable=True, comment="参数模板ID")
|
||||
param_name = Column(String(200), nullable=False, comment="参数名快照")
|
||||
param_code = Column(String(64), nullable=True, comment="参数编码快照")
|
||||
param_unit = Column(String(32), nullable=True, comment="单位快照")
|
||||
param_type = Column(String(16), nullable=False, default="text", comment="类型快照")
|
||||
param_value = Column(String(500), nullable=True, comment="填写值")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", 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: Import in `models.py`**
|
||||
|
||||
Add at the end of `backend/app/models/models.py`:
|
||||
```python
|
||||
from app.models.qc_param import BizQcItemParam, BizQcTaskResult # noqa: E402,F401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend — CRUD for item params
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/qc_item_param.py`
|
||||
|
||||
This CRUD provides:
|
||||
- `list_params_by_item(db, item_source, item_id, tenant_no)` — get all params for an item
|
||||
- `save_params_for_item(db, item_source, item_id, params_list, tenant_no, user_id)` — **full replace** strategy: soft-delete existing, create new (simplest approach)
|
||||
- `_row_to_dict(r)` — serialize
|
||||
|
||||
```python
|
||||
"""检测项目参数模板 CRUD"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.qc_param import BizQcItemParam
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcItemParam) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"paramName": r.param_name,
|
||||
"paramCode": r.param_code,
|
||||
"paramUnit": r.param_unit or "",
|
||||
"paramType": r.param_type or "text",
|
||||
"requiredFlag": r.required_flag or "0",
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def list_params_by_item(
|
||||
db: Session, item_source: str, item_id: str, tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcItemParam)
|
||||
.filter(
|
||||
BizQcItemParam.item_source == item_source,
|
||||
BizQcItemParam.item_id == item_id,
|
||||
BizQcItemParam.tenant_no == tenant_no,
|
||||
BizQcItemParam.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcItemParam.order_num.asc(), BizQcItemParam.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def save_params_for_item(
|
||||
db: Session,
|
||||
item_source: str,
|
||||
item_id: str,
|
||||
params: List[Dict[str, Any]],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Full-replace: soft-delete old params, create new ones."""
|
||||
# soft-delete existing
|
||||
existing = (
|
||||
db.query(BizQcItemParam)
|
||||
.filter(
|
||||
BizQcItemParam.item_source == item_source,
|
||||
BizQcItemParam.item_id == item_id,
|
||||
BizQcItemParam.tenant_no == tenant_no,
|
||||
BizQcItemParam.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
now = datetime.now()
|
||||
for old in existing:
|
||||
old.del_flag = 1
|
||||
old.update_by = user_id
|
||||
old.update_time = now
|
||||
db.add(old)
|
||||
|
||||
# create new
|
||||
created = []
|
||||
for idx, p in enumerate(params):
|
||||
obj = BizQcItemParam(
|
||||
id=generate_uuid(),
|
||||
item_source=item_source,
|
||||
item_id=item_id,
|
||||
param_name=p.get("paramName", "").strip(),
|
||||
param_code=p.get("paramCode", "").strip(),
|
||||
param_unit=(p.get("paramUnit") or "").strip() or None,
|
||||
param_type=p.get("paramType", "text"),
|
||||
required_flag=p.get("requiredFlag", "0"),
|
||||
tenant_no=tenant_no,
|
||||
order_num=idx,
|
||||
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)
|
||||
created.append(obj)
|
||||
db.commit()
|
||||
for obj in created:
|
||||
db.refresh(obj)
|
||||
return [_row_to_dict(r) for r in created]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Backend — CRUD for task results
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/crud/qc_task_result.py`
|
||||
|
||||
```python
|
||||
"""质检任务检测结果 CRUD"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.qc_param import BizQcTaskResult
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcTaskResult) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"paramId": r.param_id or "",
|
||||
"paramName": r.param_name,
|
||||
"paramCode": r.param_code or "",
|
||||
"paramUnit": r.param_unit or "",
|
||||
"paramType": r.param_type or "text",
|
||||
"paramValue": r.param_value or "",
|
||||
}
|
||||
|
||||
|
||||
def list_results_by_task(
|
||||
db: Session, task_id: str, tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcTaskResult)
|
||||
.filter(
|
||||
BizQcTaskResult.task_id == task_id,
|
||||
BizQcTaskResult.tenant_no == tenant_no,
|
||||
BizQcTaskResult.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcTaskResult.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def save_results_for_task(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
results: List[Dict[str, Any]],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Full-replace: soft-delete old results, create new ones."""
|
||||
existing = (
|
||||
db.query(BizQcTaskResult)
|
||||
.filter(
|
||||
BizQcTaskResult.task_id == task_id,
|
||||
BizQcTaskResult.tenant_no == tenant_no,
|
||||
BizQcTaskResult.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
now = datetime.now()
|
||||
for old in existing:
|
||||
old.del_flag = 1
|
||||
old.update_by = user_id
|
||||
old.update_time = now
|
||||
db.add(old)
|
||||
|
||||
created = []
|
||||
for p in results:
|
||||
obj = BizQcTaskResult(
|
||||
id=generate_uuid(),
|
||||
task_id=task_id,
|
||||
param_id=(p.get("paramId") or "").strip() or None,
|
||||
param_name=p.get("paramName", "").strip(),
|
||||
param_code=(p.get("paramCode") or "").strip() or None,
|
||||
param_unit=(p.get("paramUnit") or "").strip() or None,
|
||||
param_type=p.get("paramType", "text"),
|
||||
param_value=(p.get("paramValue") or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
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)
|
||||
created.append(obj)
|
||||
db.commit()
|
||||
for obj in created:
|
||||
db.refresh(obj)
|
||||
return [_row_to_dict(r) for r in created]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Backend — API endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/qc.py`
|
||||
|
||||
Add these endpoints (import the new CRUD modules at top):
|
||||
|
||||
**a) Item param endpoints:**
|
||||
|
||||
```python
|
||||
from app.crud import qc_item_param as param_svc
|
||||
from app.crud import qc_task_result as result_svc
|
||||
|
||||
|
||||
# ---------- 检测项目参数模板 ----------
|
||||
|
||||
class ItemParamBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
params: List[Dict[str, Any]] = Field(default_factory=list, alias="params")
|
||||
|
||||
|
||||
@router.get("/qc-item-params")
|
||||
async def list_item_params(
|
||||
itemSource: str = Query(...),
|
||||
itemId: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(param_svc.list_params_by_item(db, itemSource, itemId, tenant_no))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/qc-item-params")
|
||||
async def save_item_params(
|
||||
body: ItemParamBody,
|
||||
itemSource: str = Query(...),
|
||||
itemId: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(
|
||||
param_svc.save_params_for_item(db, itemSource, itemId, body.params, tenant_no, _get_current_user_id(user)),
|
||||
message="保存成功"
|
||||
)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
```
|
||||
|
||||
**b) Task result endpoints:**
|
||||
|
||||
```python
|
||||
class TaskResultBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
results: List[Dict[str, Any]] = Field(default_factory=list, alias="results")
|
||||
|
||||
|
||||
@router.get("/qc-tasks/{task_id}/results")
|
||||
async def get_task_results(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(result_svc.list_results_by_task(db, task_id, tenant_no))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
class TaskCompleteWithResultBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
results: List[Dict[str, Any]] = Field(default_factory=list, alias="results")
|
||||
third_party_attachments: Optional[str] = Field(None, alias="thirdPartyAttachments")
|
||||
|
||||
|
||||
@router.post("/qc-tasks/{task_id}/complete-with-result")
|
||||
async def complete_task_with_result(
|
||||
task_id: str,
|
||||
body: TaskCompleteWithResultBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
# save results
|
||||
if body.results:
|
||||
result_svc.save_results_for_task(db, task_id, body.results, tenant_no, user_id)
|
||||
# save attachments if provided
|
||||
if body.third_party_attachments is not None:
|
||||
task_svc.update_task_attachments(db, task_id, attachments=body.third_party_attachments, tenant_no=tenant_no, user_id=user_id)
|
||||
# complete task
|
||||
obj = task_svc.complete_task(db, task_id, tenant_no=tenant_no, user_id=user_id)
|
||||
return success_response(task_svc._task_to_dict(obj), message="已完成")
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
```
|
||||
|
||||
Also add `Dict, Any` to the `from typing import` line at top of qc.py.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — API modules
|
||||
|
||||
**Files:**
|
||||
- Create: `web-admin/src/api/qc-item-param.ts`
|
||||
- Create: `web-admin/src/api/qc-task-result.ts`
|
||||
|
||||
**`qc-item-param.ts`:**
|
||||
|
||||
```typescript
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type QcItemParam = {
|
||||
id?: string
|
||||
paramName: string
|
||||
paramCode: string
|
||||
paramUnit?: string
|
||||
paramType: string
|
||||
requiredFlag: string
|
||||
orderNum?: number
|
||||
}
|
||||
|
||||
export function fetchQcItemParams(itemSource: string, itemId: string) {
|
||||
return request.get<QcItemParam[]>({
|
||||
url: '/web/admin/qc-item-params',
|
||||
params: { itemSource, itemId }
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSaveQcItemParams(itemSource: string, itemId: string, params: QcItemParam[]) {
|
||||
return request.put<QcItemParam[]>({
|
||||
url: '/web/admin/qc-item-params',
|
||||
params: { itemSource, itemId },
|
||||
data: { params }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**`qc-task-result.ts`:**
|
||||
|
||||
```typescript
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type QcTaskResultItem = {
|
||||
paramId?: string
|
||||
paramName: string
|
||||
paramCode?: string
|
||||
paramUnit?: string
|
||||
paramType: string
|
||||
paramValue: string
|
||||
}
|
||||
|
||||
export function fetchQcTaskResults(taskId: string) {
|
||||
return request.get<QcTaskResultItem[]>({
|
||||
url: `/web/admin/qc-tasks/${taskId}/results`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCompleteTaskWithResult(
|
||||
taskId: string,
|
||||
data: { results: QcTaskResultItem[]; thirdPartyAttachments?: string }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/qc-tasks/${taskId}/complete-with-result`,
|
||||
data
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — Lab test item page: add param config
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/quality-inspection/lab-test-item/index.vue`
|
||||
|
||||
In the edit/add dialog form, add a "参数配置" section after the existing form fields (before `备注`). The section shows a dynamic table where users can add/remove parameter rows.
|
||||
|
||||
Key additions:
|
||||
- Import `fetchQcItemParams, fetchSaveQcItemParams, type QcItemParam` from `@/api/qc-item-param`
|
||||
- Add `itemParams` ref: `ref<QcItemParam[]>([])`
|
||||
- On dialog open (handleAdd/handleEdit), load existing params: `fetchQcItemParams('lab', row.id)`
|
||||
- On submit, save params alongside the item: `fetchSaveQcItemParams('lab', savedId, itemParams.value)`
|
||||
- Template: form-section with section-title "参数配置", containing a dynamic table with columns: 参数名称, 参数编码, 单位, 类型(select: text/number), 必填(switch), 操作(删除)
|
||||
- Add row button at bottom
|
||||
- CSS: reuse `.form-section` + `.section-title` pattern from third-party-company page
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Frontend — Third-party company page: add param config to item dialog
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/quality-inspection/third-party-company/index.vue`
|
||||
|
||||
Same pattern as Task 7, but inside the nested item dialog. The item_source is `'third_party'`.
|
||||
|
||||
Key additions:
|
||||
- Import param API
|
||||
- Add `itemParams` ref
|
||||
- On `handleEditItem` / `handleAddItem`, load params for the item
|
||||
- On `submitItem`, save params alongside the item
|
||||
- Template: form-section "参数配置" inside item form dialog
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Frontend — QC task page: "填写结果" dialog
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/quality-inspection/qc-task/index.vue`
|
||||
- Modify: `web-admin/src/api/qc-task.ts` — update QcTaskDetail type to include results
|
||||
|
||||
Major changes:
|
||||
|
||||
**a) New "填写结果" dialog** replaces the simple "完成" action for lab tasks (sysz):
|
||||
- When clicking "完成" on a lab task in `jxz` status, open a dialog
|
||||
- Dialog loads params for this task's item via `fetchQcTaskResults(taskId)` (if results already exist) and `fetchQcItemParams(itemSource, itemId)` (for param template)
|
||||
- Dynamically render form fields based on param type:
|
||||
- `number` → `el-input-number`
|
||||
- `text` → `el-input`
|
||||
- Include attachment upload (reuse ProtocolFinanceConfirmFields for third-party tasks)
|
||||
- Submit calls `fetchCompleteTaskWithResult(taskId, { results, thirdPartyAttachments })`
|
||||
|
||||
**b) Update QcTaskDetail type** to include results:
|
||||
```typescript
|
||||
export type QcTaskDetail = QcTask & {
|
||||
protocolNo?: string
|
||||
sampleCode?: string
|
||||
attachmentFiles?: Array<{ id?: string; fileUrl: string; fileName?: string }>
|
||||
results?: Array<{
|
||||
paramName: string
|
||||
paramUnit?: string
|
||||
paramType: string
|
||||
paramValue: string
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
**c) Update SystemViewDialog** handleView to include a "检测结果" section showing param values.
|
||||
|
||||
**d) For third-party tasks** (dsfjc): existing "上传材料" stays, "完成" now opens a combined dialog that has both attachment upload AND result params.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Verification
|
||||
|
||||
- [ ] Run `vite build` on web-admin — must pass with 0 errors
|
||||
- [ ] Run LSP diagnostics on all modified Vue files — 0 errors
|
||||
- [ ] Manually test the flow:
|
||||
1. Create a lab test item with params (e.g. "活率", "细胞总数")
|
||||
2. Create a third-party item with params
|
||||
3. Go through work order → task flow
|
||||
4. Fill in result values and complete task
|
||||
5. View completed task — see result values
|
||||
+1
-1
@@ -1 +1 @@
|
||||
68508
|
||||
55329
|
||||
@@ -8,6 +8,8 @@ from app.api.collection_protocol import router as collection_protocol_router
|
||||
from app.api.leave import router as leave_router
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"public_router",
|
||||
@@ -19,4 +21,6 @@ __all__ = [
|
||||
"leave_router",
|
||||
"operations_router",
|
||||
"ethnic_router",
|
||||
"sample_reception_router",
|
||||
"qc_router",
|
||||
]
|
||||
+28
-17
@@ -8,8 +8,9 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.data_scope import apply_data_scope
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
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.core.storage import storage
|
||||
from app.crud import tenant as tenant_svc
|
||||
@@ -262,22 +263,32 @@ async def list_users(
|
||||
keyword: Optional[str] = None,
|
||||
deptId: Optional[str] = Query(None, alias="deptId"),
|
||||
status: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_menu_code("system:user:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = user_svc.list_users(
|
||||
db, page=page, page_size=pageSize, keyword=keyword,
|
||||
dept_id=deptId, tenant_no=tenant_no, status=status,
|
||||
query = db.query(SysUser).filter(
|
||||
SysUser.del_flag == 0,
|
||||
SysUser.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(SysUser.nick_name.like(f"%{keyword}%"))
|
||||
if deptId:
|
||||
query = query.filter(SysUser.dept_id == deptId)
|
||||
if status:
|
||||
query = query.filter(SysUser.status == status)
|
||||
query = apply_data_scope(query, SysUser, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(SysUser.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [user_svc._row_to_dict(r) for r in items]
|
||||
tp = user_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_user_by_id(
|
||||
user_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_menu_code("system:user:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -407,7 +418,7 @@ async def list_dict_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),
|
||||
user: dict = Depends(require_menu_code("system:dict:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -508,7 +519,7 @@ async def list_dict_data(
|
||||
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
|
||||
dictType: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_menu_code("system:dict:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -581,7 +592,7 @@ async def delete_dict_data(
|
||||
@router.get("/dict-data/options")
|
||||
async def dict_data_options(
|
||||
dictType: str = Query(..., alias="dictType"),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_menu_code("system:dict:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -622,7 +633,7 @@ class DeptUpdateBody(BaseModel):
|
||||
@router.get("/depts")
|
||||
async def list_depts(
|
||||
keyword: Optional[str] = None,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_menu_code("system:dept:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -735,7 +746,7 @@ async def list_sites(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("system:site:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -844,7 +855,7 @@ async def list_posts(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("system:post:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -922,7 +933,7 @@ class PostUsersUpdateBody(BaseModel):
|
||||
@router.get("/posts/{post_id}/users")
|
||||
async def list_post_bound_users(
|
||||
post_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
user: dict = Depends(require_menu_code("system:post:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -981,7 +992,7 @@ async def list_item_units(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("system:unit:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -1076,7 +1087,7 @@ async def list_express_companies(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("system:express:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
@@ -1304,7 +1315,7 @@ async def list_role_management(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("system:role:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
|
||||
@@ -5,15 +5,24 @@ from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.data_scope import apply_data_scope
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_admin_or_super_admin
|
||||
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 cell_sample_type as cell_type_svc
|
||||
from app.crud import collection_region as region_svc
|
||||
from app.crud import collection_protocol as collection_protocol_svc
|
||||
from app.crud import collection_protocol_no as protocol_no_svc
|
||||
from app.crud import customer as customer_svc
|
||||
from app.crud import sample_code as sample_code_svc
|
||||
from app.models.models import SysUser
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionProtocolNo,
|
||||
BizCollectionRegion,
|
||||
BizCustomer,
|
||||
BizSampleCode,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["基础信息管理"])
|
||||
|
||||
@@ -64,13 +73,22 @@ async def list_customers(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("biz:customer:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = customer_svc.list_customers(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
query = db.query(BizCustomer).filter(
|
||||
BizCustomer.del_flag == 0,
|
||||
BizCustomer.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizCustomer.customer_name.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizCustomer, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizCustomer.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [customer_svc._row_to_dict(r) for r in items]
|
||||
tp = customer_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/customers")
|
||||
@@ -174,15 +192,22 @@ async def list_cell_sample_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),
|
||||
user: dict = Depends(require_menu_code("biz:cellSampleType:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = cell_type_svc.list_cell_sample_types(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
query = db.query(BizCellSampleType).filter(
|
||||
BizCellSampleType.del_flag == 0,
|
||||
BizCellSampleType.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizCellSampleType.type_name.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizCellSampleType, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizCellSampleType.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [cell_type_svc._row_to_dict(r) for r in items]
|
||||
tp = cell_type_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/cell-sample-types")
|
||||
@@ -276,15 +301,22 @@ async def list_collection_regions(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = region_svc.list_collection_regions(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
query = db.query(BizCollectionRegion).filter(
|
||||
BizCollectionRegion.del_flag == 0,
|
||||
BizCollectionRegion.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizCollectionRegion.region_name.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizCollectionRegion, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizCollectionRegion.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [region_svc._region_to_dict(r) for r in items]
|
||||
tp = region_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/collection-regions")
|
||||
@@ -373,7 +405,7 @@ async def list_hospitals(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("biz:collectionRegion:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
@@ -487,15 +519,29 @@ async def list_protocol_nos(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("biz:collectionProtocolNo:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = protocol_no_svc.list_protocol_nos(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
query = db.query(BizCollectionProtocolNo).filter(
|
||||
BizCollectionProtocolNo.del_flag == 0,
|
||||
BizCollectionProtocolNo.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizCollectionProtocolNo.protocol_no.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizCollectionProtocolNo, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizCollectionProtocolNo.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [protocol_no_svc._row_to_dict(r) for r in items]
|
||||
used_nos = collection_protocol_svc.get_protocol_nos_used_in_protocols(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
protocol_nos=[row["protocolNo"] for row in result_list],
|
||||
)
|
||||
for row in result_list:
|
||||
row["usedInProtocol"] = row.get("protocolNo") in used_nos
|
||||
tp = protocol_no_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
class ProtocolNoBatchGenerateBody(BaseModel):
|
||||
@@ -785,15 +831,29 @@ async def list_sample_codes(
|
||||
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),
|
||||
user: dict = Depends(require_menu_code("biz:sampleCode:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = sample_code_svc.list_sample_codes(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
|
||||
query = db.query(BizSampleCode).filter(
|
||||
BizSampleCode.del_flag == 0,
|
||||
BizSampleCode.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizSampleCode.sample_code.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizSampleCode, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizSampleCode.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [sample_code_svc._row_to_dict(r) for r in items]
|
||||
used_codes = collection_protocol_svc.get_sample_codes_used_in_protocol_samples(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_codes=[row["sampleCode"] for row in result_list],
|
||||
)
|
||||
for row in result_list:
|
||||
row["usedInProtocol"] = row.get("sampleCode") in used_codes
|
||||
tp = sample_code_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/sample-codes")
|
||||
|
||||
@@ -221,14 +221,15 @@ def _persist_protocol_sub_resources(
|
||||
cp_svc.upsert_collection_info(db, protocol_id, tenant_no, user_id, **collection_info)
|
||||
if samples is not None:
|
||||
cp_svc.save_samples(db, protocol_id, tenant_no, user_id, samples)
|
||||
pay = None
|
||||
if payment:
|
||||
cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
|
||||
pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
|
||||
if payment_details is not None:
|
||||
pay = cp_svc.get_payment_by_protocol_id(db, protocol_id, tenant_no)
|
||||
if not pay and payment:
|
||||
pay = cp_svc.upsert_payment(db, protocol_id, tenant_no, user_id, **payment)
|
||||
if pay:
|
||||
cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, payment_details)
|
||||
aligned_details = cp_svc.align_fq_payment_details_payload(pay, payment_details)
|
||||
cp_svc.save_payment_details(db, pay.id, tenant_no, user_id, aligned_details)
|
||||
if storage_cert:
|
||||
cp_svc.upsert_storage_cert(db, protocol_id, tenant_no, user_id, **storage_cert)
|
||||
|
||||
|
||||
+129
-3
@@ -78,7 +78,7 @@ STATIC_MENU_TREE = [
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "20000000000000000000000000000001",
|
||||
"id": "100",
|
||||
"name": "System",
|
||||
"path": "/system",
|
||||
"component": "/index/index",
|
||||
@@ -90,11 +90,81 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "20000000000000000000000000000002",
|
||||
"id": "101",
|
||||
"name": "Tenant",
|
||||
"path": "tenant",
|
||||
"component": "/system/tenant/index",
|
||||
"meta": {"title": "租户管理", "keepAlive": True},
|
||||
"meta": {"title": "租户管理", "keepAlive": True, "menuCode": "system:tenant:list"},
|
||||
},
|
||||
{
|
||||
"id": "102",
|
||||
"name": "Dict",
|
||||
"path": "dict",
|
||||
"component": "/system/dict/index",
|
||||
"meta": {"title": "字典管理", "keepAlive": True, "menuCode": "system:dict:list"},
|
||||
},
|
||||
{
|
||||
"id": "103",
|
||||
"name": "Dept",
|
||||
"path": "dept",
|
||||
"component": "/system/dept/index",
|
||||
"meta": {"title": "组织管理", "keepAlive": True, "menuCode": "system:dept:list"},
|
||||
},
|
||||
{
|
||||
"id": "104",
|
||||
"name": "User",
|
||||
"path": "user",
|
||||
"component": "/system/user/index",
|
||||
"meta": {"title": "用户管理", "keepAlive": True, "menuCode": "system:user:list"},
|
||||
},
|
||||
{
|
||||
"id": "10000000000000000000000000000008",
|
||||
"name": "UserDetail",
|
||||
"path": "user/detail",
|
||||
"component": "/system/user/detail",
|
||||
"meta": {"title": "用户详情", "isHide": True, "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "105",
|
||||
"name": "Role",
|
||||
"path": "role",
|
||||
"component": "/system/role/index",
|
||||
"meta": {"title": "角色管理", "keepAlive": True, "menuCode": "system:role:list"},
|
||||
},
|
||||
{
|
||||
"id": "106",
|
||||
"name": "Menu",
|
||||
"path": "menu",
|
||||
"component": "/system/menu/index",
|
||||
"meta": {"title": "菜单管理", "keepAlive": True, "menuCode": "system:menu:list"},
|
||||
},
|
||||
{
|
||||
"id": "107",
|
||||
"name": "Site",
|
||||
"path": "site",
|
||||
"component": "/system/site/index",
|
||||
"meta": {"title": "站点管理", "keepAlive": True, "menuCode": "system:site:list"},
|
||||
},
|
||||
{
|
||||
"id": "108",
|
||||
"name": "Post",
|
||||
"path": "post",
|
||||
"component": "/system/post/index",
|
||||
"meta": {"title": "职位管理", "keepAlive": True, "menuCode": "system:post:list"},
|
||||
},
|
||||
{
|
||||
"id": "109",
|
||||
"name": "Unit",
|
||||
"path": "unit",
|
||||
"component": "/system/unit/index",
|
||||
"meta": {"title": "物品单位", "keepAlive": True, "menuCode": "system:unit:list"},
|
||||
},
|
||||
{
|
||||
"id": "110",
|
||||
"name": "Express",
|
||||
"path": "express",
|
||||
"component": "/system/express/index",
|
||||
"meta": {"title": "快递公司", "keepAlive": True, "menuCode": "system:express:list"},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -196,6 +266,62 @@ STATIC_MENU_TREE = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000001",
|
||||
"name": "QualityInspection",
|
||||
"path": "/quality-inspection",
|
||||
"component": "/index/index",
|
||||
"meta": {
|
||||
"title": "质量检测",
|
||||
"icon": "ri:shield-check-line",
|
||||
"keepAlive": False,
|
||||
"roles": ["admin"],
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "70000000000000000000000000000002",
|
||||
"name": "QualityInspectionSampleReception",
|
||||
"path": "sample-reception",
|
||||
"component": "/quality-inspection/sample-reception/index",
|
||||
"meta": {"title": "样本接收", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000003",
|
||||
"name": "QualityInspectionSampleReceptionCheckItem",
|
||||
"path": "sample-reception-check-item",
|
||||
"component": "/quality-inspection/sample-reception-check-item/index",
|
||||
"meta": {"title": "接收检查项配置", "keepAlive": True},
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000004",
|
||||
"name": "QualityInspectionThirdPartyCompany",
|
||||
"path": "third-party-company",
|
||||
"component": "/quality-inspection/third-party-company/index",
|
||||
"meta": {"title": "第三方检测公司及项目", "keepAlive": True, "menuCode": "biz:qcThirdPartyCompany:list"},
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000006",
|
||||
"name": "QualityInspectionLabTestItem",
|
||||
"path": "lab-test-item",
|
||||
"component": "/quality-inspection/lab-test-item/index",
|
||||
"meta": {"title": "实验室自检项目", "keepAlive": True, "menuCode": "biz:qcLabTestItem:list"},
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000007",
|
||||
"name": "QualityInspectionQcWorkOrder",
|
||||
"path": "qc-work-order",
|
||||
"component": "/quality-inspection/qc-work-order/index",
|
||||
"meta": {"title": "质检工单", "keepAlive": True, "menuCode": "biz:qcWorkOrder:list"},
|
||||
},
|
||||
{
|
||||
"id": "70000000000000000000000000000008",
|
||||
"name": "QualityInspectionQcTask",
|
||||
"path": "qc-task",
|
||||
"component": "/quality-inspection/qc-task/index",
|
||||
"meta": {"title": "质检任务", "keepAlive": True, "menuCode": "biz:qcTask:list"},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.data_scope import apply_data_scope
|
||||
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 clean_room as clean_room_svc
|
||||
from app.crud import exhibition_appointment as exhibition_svc
|
||||
from app.crud import storage_facility as storage_facility_svc
|
||||
from app.models.models import SysUser
|
||||
from app.models.models import BizCleanRoom, BizCleanRoomAccessLog, BizExhibitionAppointment, BizStorageFacility, BizStorageFacilitySlot, SysUser
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["运营支撑"])
|
||||
|
||||
@@ -65,9 +66,18 @@ async def list_clean_rooms(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = clean_room_svc.list_clean_rooms(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
query = db.query(BizCleanRoom).filter(
|
||||
BizCleanRoom.del_flag == 0,
|
||||
BizCleanRoom.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizCleanRoom.room_name.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizCleanRoom, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizCleanRoom.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [clean_room_svc._row_to_dict(r) for r in items]
|
||||
tp = clean_room_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/clean-rooms")
|
||||
@@ -185,9 +195,23 @@ async def list_access_logs(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = clean_room_svc.list_access_logs(db, page=page, page_size=pageSize, clean_room_id=cleanRoomId, keyword=keyword, tenant_no=tenant_no)
|
||||
query = db.query(BizCleanRoomAccessLog).filter(
|
||||
BizCleanRoomAccessLog.del_flag == 0,
|
||||
BizCleanRoomAccessLog.tenant_no == tenant_no,
|
||||
BizCleanRoomAccessLog.clean_room_id == cleanRoomId,
|
||||
)
|
||||
if keyword:
|
||||
kw = f"%{keyword}%"
|
||||
query = query.filter(
|
||||
BizCleanRoomAccessLog.person_name.like(kw)
|
||||
| BizCleanRoomAccessLog.dept_name.like(kw)
|
||||
)
|
||||
query = apply_data_scope(query, BizCleanRoomAccessLog, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizCleanRoomAccessLog.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [clean_room_svc._access_log_to_dict(r) for r in items]
|
||||
tp = clean_room_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/clean-room-access-logs")
|
||||
@@ -293,8 +317,16 @@ async def list_storage_facilities(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = storage_facility_svc.list_all_facilities(db, tenant_no=tenant_no, keyword=keyword)
|
||||
return success_response(items)
|
||||
query = db.query(BizStorageFacility).filter(
|
||||
BizStorageFacility.del_flag == 0,
|
||||
BizStorageFacility.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizStorageFacility.node_name.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizStorageFacility, user, db, tenant_no)
|
||||
items = query.order_by(BizStorageFacility.create_time.desc()).all()
|
||||
result_list = [storage_facility_svc._row_to_dict(r) for r in items]
|
||||
return success_response(result_list)
|
||||
|
||||
|
||||
@router.post("/storage-facilities")
|
||||
@@ -400,8 +432,15 @@ async def list_storage_slots(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = storage_facility_svc.list_slots(db, facility_node_id=facilityNodeId, tenant_no=tenant_no)
|
||||
return success_response(items)
|
||||
query = db.query(BizStorageFacilitySlot).filter(
|
||||
BizStorageFacilitySlot.del_flag == 0,
|
||||
BizStorageFacilitySlot.tenant_no == tenant_no,
|
||||
BizStorageFacilitySlot.facility_node_id == facilityNodeId,
|
||||
)
|
||||
query = apply_data_scope(query, BizStorageFacilitySlot, user, db, tenant_no)
|
||||
items = query.order_by(BizStorageFacilitySlot.order_num.asc(), BizStorageFacilitySlot.create_time.asc()).all()
|
||||
result_list = [storage_facility_svc._slot_to_dict(r) for r in items]
|
||||
return success_response(result_list)
|
||||
|
||||
|
||||
@router.post("/storage-facility-slots/batch")
|
||||
@@ -527,9 +566,18 @@ async def list_exhibition_appointments(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = exhibition_svc.list_exhibition_appointments(db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no)
|
||||
query = db.query(BizExhibitionAppointment).filter(
|
||||
BizExhibitionAppointment.del_flag == 0,
|
||||
BizExhibitionAppointment.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizExhibitionAppointment.visit_org.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizExhibitionAppointment, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizExhibitionAppointment.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [exhibition_svc._row_to_dict(r) for r in items]
|
||||
tp = exhibition_svc.total_pages(total, pageSize)
|
||||
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
return success_response({"list": result_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
|
||||
|
||||
|
||||
@router.post("/exhibition-appointments")
|
||||
|
||||
@@ -269,15 +269,6 @@ async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db
|
||||
except OperationalError:
|
||||
menu_codes = []
|
||||
|
||||
if role == "admin" and not menu_codes:
|
||||
return success_response(
|
||||
{
|
||||
"isSuperAdmin": False,
|
||||
"all": True,
|
||||
"menuCodes": [],
|
||||
}
|
||||
)
|
||||
|
||||
return success_response(
|
||||
{
|
||||
"isSuperAdmin": False,
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
"""质量检测 — 第三方/实验室主数据、质检工单、质检任务 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, require_menu_code
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import qc_lab_test_item as lab_svc
|
||||
from app.crud import qc_item_param as param_svc
|
||||
from app.crud import qc_task_result as result_svc
|
||||
from app.crud import qc_task as task_svc
|
||||
from app.crud import qc_third_party_company as tpc_svc
|
||||
from app.crud import qc_third_party_item as tpi_svc
|
||||
from app.crud import qc_work_order as wo_svc
|
||||
from app.crud.collection_protocol import total_pages
|
||||
from app.api.sample_reception import _get_current_user_id, _get_tenant_no, _page_payload
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["质量检测"])
|
||||
|
||||
|
||||
# ---------- 第三方检测公司 ----------
|
||||
|
||||
|
||||
class ThirdPartyCompanyBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
company_code: str = Field(..., alias="companyCode")
|
||||
company_name: str = Field(..., alias="companyName")
|
||||
contact_name: Optional[str] = Field(None, alias="contactName")
|
||||
contact_phone: Optional[str] = Field(None, alias="contactPhone")
|
||||
address: Optional[str] = None
|
||||
invoice_title: Optional[str] = Field(None, alias="invoiceTitle")
|
||||
tax_id: Optional[str] = Field(None, alias="taxId")
|
||||
bank_name: Optional[str] = Field(None, alias="bankName")
|
||||
bank_account: Optional[str] = Field(None, alias="bankAccount")
|
||||
status: str = "1"
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ThirdPartyCompanyUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
company_code: Optional[str] = Field(None, alias="companyCode")
|
||||
company_name: Optional[str] = Field(None, alias="companyName")
|
||||
contact_name: Optional[str] = Field(None, alias="contactName")
|
||||
contact_phone: Optional[str] = Field(None, alias="contactPhone")
|
||||
address: Optional[str] = None
|
||||
invoice_title: Optional[str] = Field(None, alias="invoiceTitle")
|
||||
tax_id: Optional[str] = Field(None, alias="taxId")
|
||||
bank_name: Optional[str] = Field(None, alias="bankName")
|
||||
bank_account: Optional[str] = Field(None, alias="bankAccount")
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/qc-third-party-companies")
|
||||
async def list_qc_third_party_companies(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = tpc_svc.list_companies(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, status=status, tenant_no=tenant_no
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/qc-third-party-companies/options")
|
||||
async def list_qc_third_party_company_options(
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(tpc_svc.list_company_options(db, tenant_no))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-third-party-companies")
|
||||
async def create_qc_third_party_company(
|
||||
body: ThirdPartyCompanyBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = tpc_svc.create_company(
|
||||
db,
|
||||
company_code=body.company_code,
|
||||
company_name=body.company_name,
|
||||
contact_name=body.contact_name,
|
||||
contact_phone=body.contact_phone,
|
||||
address=body.address,
|
||||
invoice_title=body.invoice_title,
|
||||
tax_id=body.tax_id,
|
||||
bank_name=body.bank_name,
|
||||
bank_account=body.bank_account,
|
||||
status=body.status,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
)
|
||||
return success_response(tpc_svc._row_to_dict(obj), message="创建成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/qc-third-party-companies/{company_id}")
|
||||
async def update_qc_third_party_company(
|
||||
company_id: str,
|
||||
body: ThirdPartyCompanyUpdateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = tpc_svc.update_company(
|
||||
db,
|
||||
company_id,
|
||||
company_code=body.company_code,
|
||||
company_name=body.company_name,
|
||||
contact_name=body.contact_name,
|
||||
contact_phone=body.contact_phone,
|
||||
address=body.address,
|
||||
invoice_title=body.invoice_title,
|
||||
tax_id=body.tax_id,
|
||||
bank_name=body.bank_name,
|
||||
bank_account=body.bank_account,
|
||||
status=body.status,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(tpc_svc._row_to_dict(obj), message="更新成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.delete("/qc-third-party-companies/{company_id}")
|
||||
async def delete_qc_third_party_company(
|
||||
company_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
tpc_svc.soft_delete_company(db, company_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 第三方检测项目 ----------
|
||||
|
||||
|
||||
class ThirdPartyItemBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
company_id: str = Field(..., alias="companyId")
|
||||
item_code: str = Field(..., alias="itemCode")
|
||||
item_name: str = Field(..., alias="itemName")
|
||||
fee_amount: float = Field(..., alias="feeAmount")
|
||||
status: str = "1"
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class ThirdPartyItemUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
company_id: Optional[str] = Field(None, alias="companyId")
|
||||
item_code: Optional[str] = Field(None, alias="itemCode")
|
||||
item_name: Optional[str] = Field(None, alias="itemName")
|
||||
fee_amount: Optional[float] = Field(None, alias="feeAmount")
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/qc-third-party-items")
|
||||
async def list_qc_third_party_items(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
companyId: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = tpi_svc.list_items(
|
||||
db,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
status=status,
|
||||
company_id=companyId,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/qc-third-party-items/active")
|
||||
async def list_qc_third_party_items_active(
|
||||
companyId: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(tpi_svc.list_active_items(db, tenant_no, company_id=companyId))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-third-party-items")
|
||||
async def create_qc_third_party_item(
|
||||
body: ThirdPartyItemBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = tpi_svc.create_item(
|
||||
db,
|
||||
company_id=body.company_id,
|
||||
item_code=body.item_code,
|
||||
item_name=body.item_name,
|
||||
fee_amount=body.fee_amount,
|
||||
status=body.status,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
)
|
||||
return success_response(tpi_svc._row_to_dict(obj, ""), message="创建成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/qc-third-party-items/{item_id}")
|
||||
async def update_qc_third_party_item(
|
||||
item_id: str,
|
||||
body: ThirdPartyItemUpdateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = tpi_svc.update_item(
|
||||
db,
|
||||
item_id,
|
||||
company_id=body.company_id,
|
||||
item_code=body.item_code,
|
||||
item_name=body.item_name,
|
||||
fee_amount=body.fee_amount,
|
||||
status=body.status,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(tpi_svc._row_to_dict(obj, ""), message="更新成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.delete("/qc-third-party-items/{item_id}")
|
||||
async def delete_qc_third_party_item(
|
||||
item_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcThirdPartyCompany:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
tpi_svc.soft_delete_item(db, item_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 实验室自检项目 ----------
|
||||
|
||||
|
||||
class LabTestItemBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
item_code: str = Field(..., alias="itemCode")
|
||||
item_name: str = Field(..., alias="itemName")
|
||||
description: Optional[str] = None
|
||||
status: str = "1"
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class LabTestItemUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
item_code: Optional[str] = Field(None, alias="itemCode")
|
||||
item_name: Optional[str] = Field(None, alias="itemName")
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- 检测项目参数模板 ----------
|
||||
|
||||
|
||||
class ItemParamBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
params: List[Dict[str, Any]] = Field(default_factory=list, alias="params")
|
||||
|
||||
|
||||
class TaskResultBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
results: List[Dict[str, Any]] = Field(default_factory=list, alias="results")
|
||||
|
||||
|
||||
class TaskCompleteWithResultBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
results: List[Dict[str, Any]] = Field(default_factory=list, alias="results")
|
||||
third_party_attachments: Optional[str] = Field(None, alias="thirdPartyAttachments")
|
||||
|
||||
|
||||
@router.get("/qc-lab-test-items")
|
||||
async def list_qc_lab_test_items(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = lab_svc.list_lab_items(
|
||||
db, page=page, page_size=pageSize, keyword=keyword, status=status, tenant_no=tenant_no
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/qc-lab-test-items/active")
|
||||
async def list_qc_lab_test_items_active(
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(lab_svc.list_active_lab_items(db, tenant_no))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-lab-test-items")
|
||||
async def create_qc_lab_test_item(
|
||||
body: LabTestItemBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = lab_svc.create_lab_item(
|
||||
db,
|
||||
item_code=body.item_code,
|
||||
item_name=body.item_name,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
)
|
||||
return success_response(lab_svc._row_to_dict(obj), message="创建成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/qc-lab-test-items/{item_id}")
|
||||
async def update_qc_lab_test_item(
|
||||
item_id: str,
|
||||
body: LabTestItemUpdateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = lab_svc.update_lab_item(
|
||||
db,
|
||||
item_id,
|
||||
item_code=body.item_code,
|
||||
item_name=body.item_name,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=_get_current_user_id(user),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(lab_svc._row_to_dict(obj), message="更新成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.delete("/qc-lab-test-items/{item_id}")
|
||||
async def delete_qc_lab_test_item(
|
||||
item_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
lab_svc.soft_delete_lab_item(db, item_id, tenant_no=tenant_no)
|
||||
return success_response(None, message="删除成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 检测项目参数模板 ----------
|
||||
|
||||
|
||||
@router.get("/qc-item-params")
|
||||
async def list_item_params(
|
||||
itemSource: str = Query(...),
|
||||
itemId: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(param_svc.list_params_by_item(db, itemSource, itemId, tenant_no))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/qc-item-params")
|
||||
async def save_item_params(
|
||||
body: ItemParamBody,
|
||||
itemSource: str = Query(...),
|
||||
itemId: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcLabTestItem:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(
|
||||
param_svc.save_params_for_item(db, itemSource, itemId, body.params, tenant_no, _get_current_user_id(user)),
|
||||
message="保存成功",
|
||||
)
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 质检工单 ----------
|
||||
|
||||
|
||||
class WorkOrderConfigureBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
has_lab: bool = Field(False, alias="hasLab")
|
||||
has_third_party: bool = Field(False, alias="hasThirdParty")
|
||||
lab_item_ids: List[str] = Field(default_factory=list, alias="labItemIds")
|
||||
third_party_item_ids: List[str] = Field(default_factory=list, alias="thirdPartyItemIds")
|
||||
|
||||
|
||||
@router.get("/qc-work-orders")
|
||||
async def list_qc_work_orders(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
workOrderStatus: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = wo_svc.list_work_orders(
|
||||
db,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
work_order_status=workOrderStatus,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/qc-work-orders/{work_order_id}")
|
||||
async def get_qc_work_order_detail(
|
||||
work_order_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = wo_svc.get_work_order_detail(db, work_order_id, tenant_no)
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-work-orders/{work_order_id}/configure")
|
||||
async def configure_qc_work_order(
|
||||
work_order_id: str,
|
||||
body: WorkOrderConfigureBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcWorkOrder:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = wo_svc.configure_work_order(
|
||||
db,
|
||||
work_order_id,
|
||||
has_lab=body.has_lab,
|
||||
has_third_party=body.has_third_party,
|
||||
lab_item_ids=body.lab_item_ids,
|
||||
third_party_item_ids=body.third_party_item_ids,
|
||||
tenant_no=tenant_no,
|
||||
user_id=_get_current_user_id(user),
|
||||
)
|
||||
return success_response(wo_svc._wo_to_dict(obj), message="配置成功")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 质检任务 ----------
|
||||
|
||||
|
||||
class TaskAttachmentsBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
third_party_attachments: str = Field("", alias="thirdPartyAttachments")
|
||||
|
||||
|
||||
@router.get("/qc-tasks")
|
||||
async def list_qc_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
taskType: Optional[str] = None,
|
||||
taskStatus: Optional[str] = None,
|
||||
workOrderId: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = task_svc.list_tasks(
|
||||
db,
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
keyword=keyword,
|
||||
task_type=taskType,
|
||||
task_status=taskStatus,
|
||||
work_order_id=workOrderId,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/qc-tasks/{task_id}")
|
||||
async def get_qc_task_detail(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
data = task_svc.get_task_detail(db, task_id, tenant_no)
|
||||
return success_response(data)
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-tasks/{task_id}/start")
|
||||
async def start_qc_task(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = task_svc.start_task(db, task_id, tenant_no=tenant_no, user_id=_get_current_user_id(user))
|
||||
return success_response(task_svc._task_to_dict(obj), message="已开始")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-tasks/{task_id}/complete")
|
||||
async def complete_qc_task(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = task_svc.complete_task(
|
||||
db, task_id, tenant_no=tenant_no, user_id=_get_current_user_id(user)
|
||||
)
|
||||
return success_response(task_svc._task_to_dict(obj), message="已完成")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/qc-tasks/{task_id}/results")
|
||||
async def get_task_results(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
return success_response(result_svc.list_results_by_task(db, task_id, tenant_no))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/qc-tasks/{task_id}/complete-with-result")
|
||||
async def complete_task_with_result(
|
||||
task_id: str,
|
||||
body: TaskCompleteWithResultBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
if body.results:
|
||||
result_svc.save_results_for_task(db, task_id, body.results, tenant_no, user_id)
|
||||
if body.third_party_attachments is not None:
|
||||
task_svc.update_task_attachments(
|
||||
db, task_id, attachments=body.third_party_attachments,
|
||||
tenant_no=tenant_no, user_id=user_id,
|
||||
)
|
||||
obj = task_svc.complete_task(db, task_id, tenant_no=tenant_no, user_id=user_id)
|
||||
return success_response(task_svc._task_to_dict(obj), message="已完成")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/qc-tasks/{task_id}/attachments")
|
||||
async def update_qc_task_attachments(
|
||||
task_id: str,
|
||||
body: TaskAttachmentsBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:qcTask:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = task_svc.update_task_attachments(
|
||||
db,
|
||||
task_id,
|
||||
attachments=body.third_party_attachments,
|
||||
tenant_no=tenant_no,
|
||||
user_id=_get_current_user_id(user),
|
||||
)
|
||||
return success_response(task_svc._task_to_dict(obj), message="已保存")
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,327 @@
|
||||
"""质量检测 — 样本接收 API"""
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.data_scope import apply_data_scope
|
||||
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.core.utils import parse_datetime
|
||||
from app.crud import sample_reception as sr_svc
|
||||
from app.crud import sample_reception_check_item as check_item_svc
|
||||
from app.crud.collection_protocol import total_pages
|
||||
from app.models.models import BizSampleReception, BizSampleReceptionCheckItem, 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
|
||||
|
||||
|
||||
def _get_current_user_id(user: dict) -> str:
|
||||
user_id = user.get("user_id")
|
||||
if not user_id:
|
||||
raise PermissionError("用户身份信息缺失")
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def _page_payload(items: list, total: int, page: int, page_size: int) -> dict:
|
||||
return {
|
||||
"list": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"totalPages": total_pages(total, page_size),
|
||||
}
|
||||
|
||||
|
||||
# ---------- 检查项配置 ----------
|
||||
|
||||
|
||||
class CheckItemBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
item_code: str = Field(..., alias="itemCode")
|
||||
item_name: str = Field(..., alias="itemName")
|
||||
status: str = Field("1", alias="status")
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class CheckItemUpdateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
item_code: Optional[str] = Field(None, alias="itemCode")
|
||||
item_name: Optional[str] = Field(None, alias="itemName")
|
||||
status: Optional[str] = None
|
||||
order_num: Optional[int] = Field(None, alias="orderNum")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/sample-reception-check-items")
|
||||
async def list_check_items(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:sampleReception:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
query = db.query(BizSampleReceptionCheckItem).filter(
|
||||
BizSampleReceptionCheckItem.del_flag == 0,
|
||||
BizSampleReceptionCheckItem.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizSampleReceptionCheckItem.item_name.like(f"%{keyword}%"))
|
||||
if status:
|
||||
query = query.filter(BizSampleReceptionCheckItem.status == status)
|
||||
query = apply_data_scope(query, BizSampleReceptionCheckItem, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizSampleReceptionCheckItem.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [check_item_svc._row_to_dict(r) for r in items]
|
||||
return success_response(_page_payload(result_list, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/sample-reception-check-items/active")
|
||||
async def list_active_check_items(
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:sampleReception:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items = check_item_svc.list_active_check_items(db, tenant_no)
|
||||
return success_response(items)
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.post("/sample-reception-check-items")
|
||||
async def create_check_item(
|
||||
body: CheckItemBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
dept_id = (sys_user.dept_id or "") if sys_user else ""
|
||||
obj = check_item_svc.create_check_item(
|
||||
db,
|
||||
item_code=body.item_code,
|
||||
item_name=body.item_name,
|
||||
status=body.status,
|
||||
tenant_no=tenant_no,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user_id,
|
||||
create_dept_id=dept_id,
|
||||
)
|
||||
return success_response(check_item_svc._row_to_dict(obj))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.put("/sample-reception-check-items/{row_id}")
|
||||
async def update_check_item(
|
||||
row_id: str,
|
||||
body: CheckItemUpdateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
obj = check_item_svc.update_check_item(
|
||||
db,
|
||||
row_id,
|
||||
item_code=body.item_code,
|
||||
item_name=body.item_name,
|
||||
status=body.status,
|
||||
order_num=body.order_num,
|
||||
remark=body.remark,
|
||||
user_id=user_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response(check_item_svc._row_to_dict(obj))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.delete("/sample-reception-check-items/{row_id}")
|
||||
async def delete_check_item(
|
||||
row_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
check_item_svc.soft_delete_check_item(db, row_id, tenant_no=tenant_no)
|
||||
return success_response(None)
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
# ---------- 样本接收 ----------
|
||||
|
||||
|
||||
@router.get("/sample-receptions/pending")
|
||||
async def list_pending_samples(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:sampleReception:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
items, total = sr_svc.list_pending_protocol_samples(
|
||||
db, page=page, page_size=pageSize, tenant_no=tenant_no, keyword=keyword
|
||||
)
|
||||
return success_response(_page_payload(items, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/sample-receptions/received")
|
||||
async def list_received_samples(
|
||||
page: int = Query(1, ge=1),
|
||||
pageSize: int = Query(10, ge=1, le=100),
|
||||
keyword: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:sampleReception:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
query = db.query(BizSampleReception).filter(
|
||||
BizSampleReception.del_flag == 0,
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(BizSampleReception.reception_no.like(f"%{keyword}%"))
|
||||
query = apply_data_scope(query, BizSampleReception, user, db, tenant_no)
|
||||
total = query.count()
|
||||
items = query.order_by(BizSampleReception.create_time.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
result_list = [sr_svc.get_reception_by_id(db, r.id, tenant_no) for r in items]
|
||||
return success_response(_page_payload(result_list, total, page, pageSize))
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
@router.get("/sample-receptions/{reception_id}")
|
||||
async def get_sample_reception_detail(
|
||||
reception_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_menu_code("biz:sampleReception:list")),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
item = sr_svc.get_reception_by_id(db, reception_id, tenant_no)
|
||||
if not item:
|
||||
return error_response("接收记录不存在", code=404)
|
||||
return success_response(item)
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
class ReceptionCheckSubmit(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
check_item_id: str = Field(..., alias="checkItemId")
|
||||
checked: bool = False
|
||||
|
||||
|
||||
class SampleReceptionCreateBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
protocol_sample_id: str = Field(..., alias="protocolSampleId")
|
||||
reception_time: str = Field(..., alias="receptionTime")
|
||||
handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
|
||||
handler_name: Optional[str] = Field(None, alias="handlerName")
|
||||
receive_temperature: Optional[str] = Field(None, alias="receiveTemperature")
|
||||
volume_ml: Optional[float] = Field(None, alias="volumeMl")
|
||||
net_weight_g: Optional[float] = Field(None, alias="netWeightG")
|
||||
checks: List[ReceptionCheckSubmit] = Field(default_factory=list)
|
||||
accept_conclusion: str = Field(..., alias="acceptConclusion")
|
||||
receive_attachments: Optional[str] = Field(None, alias="receiveAttachments")
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/sample-receptions")
|
||||
async def post_sample_reception(
|
||||
body: SampleReceptionCreateBody,
|
||||
db: Session = Depends(get_db),
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
):
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
dept_id = (sys_user.dept_id or "") if sys_user else ""
|
||||
rt = parse_datetime(body.reception_time)
|
||||
if not rt:
|
||||
return error_response("样本接收时间格式无效")
|
||||
checks_payload = [
|
||||
{"checkItemId": c.check_item_id, "checked": c.checked} for c in body.checks
|
||||
]
|
||||
obj = sr_svc.create_sample_reception(
|
||||
db,
|
||||
protocol_sample_id=body.protocol_sample_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=dept_id,
|
||||
reception_time=rt,
|
||||
handler_user_id=body.handler_user_id or user_id,
|
||||
handler_name=body.handler_name,
|
||||
receive_temperature=body.receive_temperature,
|
||||
volume_ml=body.volume_ml,
|
||||
net_weight_g=body.net_weight_g,
|
||||
checks=checks_payload,
|
||||
accept_conclusion=body.accept_conclusion,
|
||||
receive_attachments=body.receive_attachments,
|
||||
remark=body.remark,
|
||||
)
|
||||
detail = sr_svc.get_reception_by_id(db, obj.id, tenant_no)
|
||||
return success_response(detail or {"id": obj.id})
|
||||
except PermissionError as e:
|
||||
return error_response(str(e), code=403)
|
||||
except ValueError as e:
|
||||
return error_response(str(e))
|
||||
except Exception as e:
|
||||
return error_response(str(e))
|
||||
@@ -0,0 +1,198 @@
|
||||
"""数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)
|
||||
|
||||
data_scope 值:
|
||||
1 = 全部数据
|
||||
2 = 本部门数据
|
||||
3 = 本部门及以下数据
|
||||
4 = 仅本人数据
|
||||
5 = 自定义(sys_role_data_scope_detail)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Set, Tuple
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import (
|
||||
SysDept,
|
||||
SysRole,
|
||||
SysRoleDataScopeDetail,
|
||||
SysUser,
|
||||
SysUserRole,
|
||||
)
|
||||
|
||||
|
||||
def get_user_dept_id(db: Session, user_id: str) -> str | None:
|
||||
"""从 SysUser 读取用户的 dept_id。"""
|
||||
row = (
|
||||
db.query(SysUser.dept_id)
|
||||
.filter(SysUser.id == user_id, SysUser.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
return row[0] if row and row[0] else None
|
||||
|
||||
|
||||
def _get_child_dept_ids(db: Session, dept_id: str, tenant_no: str) -> List[str]:
|
||||
"""递归获取 dept_id 及其所有子部门 ID(含自身)。"""
|
||||
result: List[str] = [dept_id]
|
||||
# 用循环逐层扩展,避免深度递归栈溢出
|
||||
queue: List[str] = [dept_id]
|
||||
while queue:
|
||||
parents = queue
|
||||
queue = []
|
||||
children = (
|
||||
db.query(SysDept.id)
|
||||
.filter(
|
||||
SysDept.parent_id.in_(parents),
|
||||
SysDept.del_flag == 0,
|
||||
SysDept.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for child in children:
|
||||
cid = child[0]
|
||||
if cid not in result:
|
||||
result.append(cid)
|
||||
queue.append(cid)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_user_role_scopes(
|
||||
db: Session, user_id: str, tenant_no: str
|
||||
) -> List[dict]:
|
||||
"""获取用户所有角色的数据权限配置。
|
||||
|
||||
返回: [{"data_scope": str, "details": [SysRoleDataScopeDetail, ...]}]
|
||||
"""
|
||||
roles = (
|
||||
db.query(SysRole)
|
||||
.join(SysUserRole, SysUserRole.role_id == SysRole.id)
|
||||
.filter(
|
||||
SysUserRole.user_id == user_id,
|
||||
SysUserRole.del_flag == 0,
|
||||
SysUserRole.tenant_no == tenant_no,
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.status == "1",
|
||||
SysRole.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not roles:
|
||||
return []
|
||||
|
||||
result: List[dict] = []
|
||||
role_ids = [r.id for r in roles]
|
||||
|
||||
# 批量查所有自定义权限明细
|
||||
all_details = (
|
||||
db.query(SysRoleDataScopeDetail)
|
||||
.filter(
|
||||
SysRoleDataScopeDetail.role_id.in_(role_ids),
|
||||
SysRoleDataScopeDetail.del_flag == 0,
|
||||
SysRoleDataScopeDetail.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
details_by_role: dict[str, list] = {}
|
||||
for d in all_details:
|
||||
details_by_role.setdefault(d.role_id, []).append(d)
|
||||
|
||||
for role in roles:
|
||||
result.append(
|
||||
{
|
||||
"data_scope": role.data_scope or "1",
|
||||
"details": details_by_role.get(role.id, []),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def apply_data_scope(query, model, user_info: dict, db: Session, tenant_no: str = None):
|
||||
"""根据用户角色的数据权限过滤查询。
|
||||
|
||||
Args:
|
||||
query: SQLAlchemy query 对象
|
||||
model: 查询的主模型类(需要有 create_dept_id / create_by 字段)
|
||||
user_info: 当前用户信息字典,需含 role / user_id / dept_id 等
|
||||
db: 数据库 Session
|
||||
tenant_no: 租户编号,默认使用 settings.DEFAULT_TENANT_NO
|
||||
|
||||
Returns:
|
||||
应用数据权限过滤后的 query
|
||||
"""
|
||||
# 1. super_admin 直接放行
|
||||
if user_info.get("role") == "super_admin":
|
||||
return query
|
||||
|
||||
# 2. 获取 user_id
|
||||
user_id = user_info.get("user_id")
|
||||
if not user_id:
|
||||
return query
|
||||
|
||||
# 3. 确定 tenant_no
|
||||
effective_tenant = tenant_no or settings.DEFAULT_TENANT_NO
|
||||
|
||||
# 4. 检查 model 是否有可过滤字段
|
||||
has_dept = hasattr(model, "create_dept_id")
|
||||
has_user = hasattr(model, "create_by")
|
||||
if not has_dept and not has_user:
|
||||
return query
|
||||
|
||||
# 5. 获取用户所有角色的数据权限
|
||||
role_scopes = _resolve_user_role_scopes(db, user_id, effective_tenant)
|
||||
if not role_scopes:
|
||||
return query
|
||||
|
||||
# 6. 任一角色拥有全部数据权限 → 放行(union 语义)
|
||||
if any(rs["data_scope"] == "1" for rs in role_scopes):
|
||||
return query
|
||||
|
||||
# 7. 收集所有角色的 dept_ids 和 user_ids(union 合并)
|
||||
dept_ids: Set[str] = set()
|
||||
user_ids: Set[str] = set()
|
||||
|
||||
# 获取用户部门
|
||||
user_dept_id = user_info.get("dept_id") or user_info.get("region_id") or get_user_dept_id(db, user_id)
|
||||
|
||||
for rs in role_scopes:
|
||||
scope = rs["data_scope"]
|
||||
|
||||
if scope == "2":
|
||||
# 本部门
|
||||
if user_dept_id:
|
||||
dept_ids.add(user_dept_id)
|
||||
|
||||
elif scope == "3":
|
||||
# 本部门及以下
|
||||
if user_dept_id:
|
||||
dept_ids.update(_get_child_dept_ids(db, user_dept_id, effective_tenant))
|
||||
|
||||
elif scope == "4":
|
||||
# 仅本人
|
||||
user_ids.add(user_id)
|
||||
|
||||
elif scope == "5":
|
||||
# 自定义
|
||||
for detail in rs["details"]:
|
||||
val = (detail.scope_value or "").strip()
|
||||
if not val:
|
||||
continue
|
||||
if detail.scope_type == "dept":
|
||||
dept_ids.add(val)
|
||||
elif detail.scope_type == "user":
|
||||
user_ids.add(val)
|
||||
|
||||
# 8. 构建 OR 条件并应用
|
||||
conditions = []
|
||||
if dept_ids and has_dept:
|
||||
conditions.append(model.create_dept_id.in_(dept_ids))
|
||||
if user_ids and has_user:
|
||||
conditions.append(model.create_by.in_(user_ids))
|
||||
|
||||
if not conditions:
|
||||
# 没有任何可匹配条件 → 返回空结果
|
||||
return query.filter(False)
|
||||
|
||||
return query.filter(or_(*conditions))
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import calendar
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -28,6 +29,50 @@ from app.models.models import (
|
||||
)
|
||||
|
||||
|
||||
def get_protocol_nos_used_in_protocols(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
protocol_nos: List[str],
|
||||
) -> set:
|
||||
"""返回已在协议单主表中占用的协议编号集合(同租户、未删除)。"""
|
||||
nos = list({(n or "").strip() for n in protocol_nos if (n or "").strip()})
|
||||
if not nos:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(BizCollectionProtocol.protocol_no)
|
||||
.filter(
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
BizCollectionProtocol.protocol_no.in_(nos),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r[0] for r in rows if r[0]}
|
||||
|
||||
|
||||
def get_sample_codes_used_in_protocol_samples(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
sample_codes: List[str],
|
||||
) -> set:
|
||||
"""返回已在协议单样本表中占用的样本编码集合(同租户、未删除)。"""
|
||||
codes = list({(c or "").strip() for c in sample_codes if (c or "").strip()})
|
||||
if not codes:
|
||||
return set()
|
||||
rows = (
|
||||
db.query(BizProtocolSample.sample_code)
|
||||
.filter(
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.sample_code.in_(codes),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r[0] for r in rows if r[0]}
|
||||
|
||||
|
||||
def total_pages(total: int, page_size: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
@@ -86,6 +131,17 @@ def _normalize_fields(fields: Optional[dict]) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _pop_attachment_urls(raw: Dict[str, Any], *keys: str) -> Optional[str]:
|
||||
"""从保存载荷中取出附件字段(支持 snake/camel、URL 串或附件对象列表)。"""
|
||||
for key in keys:
|
||||
if key not in raw:
|
||||
continue
|
||||
urls = attachments_to_urls(raw.pop(key))
|
||||
if urls:
|
||||
return urls
|
||||
return None
|
||||
|
||||
|
||||
def attachments_to_urls(attachments: Any) -> Optional[str]:
|
||||
"""附件列表或字符串 → 逗号分隔 URL(MinIO)。"""
|
||||
if attachments is None:
|
||||
@@ -628,10 +684,17 @@ def get_protocol_detail_bundle(
|
||||
out["collectionInfo"] = ci_dict
|
||||
transport_rows = list_sample_transports(db, protocol_id, tenant_no)
|
||||
transport_by_sample = {t.sample_id: _transport_to_dict(t) for t in transport_rows}
|
||||
from app.crud import sample_reception as sample_reception_svc
|
||||
|
||||
sample_ids = [s.id for s in s_rows]
|
||||
reception_by_sample = sample_reception_svc.get_receptions_map_for_samples(
|
||||
db, sample_ids, tenant_no
|
||||
)
|
||||
samples_out: List[Dict[str, Any]] = []
|
||||
for s in s_rows:
|
||||
item = _sample_to_dict(s)
|
||||
item["transport"] = transport_by_sample.get(s.id)
|
||||
item["sampleReception"] = reception_by_sample.get(s.id)
|
||||
samples_out.append(item)
|
||||
out["samples"] = samples_out
|
||||
out["storageCert"] = _storage_cert_to_dict(sc) if sc else None
|
||||
@@ -1601,7 +1664,7 @@ def list_samples(db: Session, collection_protocol_id: str, tenant_no: str) -> Li
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.order_by(BizProtocolSample.create_time.asc())
|
||||
.order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1776,6 +1839,100 @@ def get_storage_cert(db: Session, collection_protocol_id: str, tenant_no: str) -
|
||||
)
|
||||
|
||||
|
||||
def _add_months_yyyymmdd(date_str: str, months: int) -> str:
|
||||
raw = (date_str or "").strip().replace("-", "")
|
||||
if len(raw) != 8:
|
||||
return ""
|
||||
y = int(raw[0:4])
|
||||
m = int(raw[4:6])
|
||||
d = int(raw[6:8])
|
||||
month_index = m - 1 + months
|
||||
ny = y + month_index // 12
|
||||
nm = month_index % 12 + 1
|
||||
last_day = calendar.monthrange(ny, nm)[1]
|
||||
nd = min(d, last_day)
|
||||
return f"{ny:04d}{nm:02d}{nd:02d}"
|
||||
|
||||
|
||||
def _calc_installment_due_amounts(
|
||||
contract_amount: Optional[Decimal],
|
||||
count: int,
|
||||
) -> List[Decimal]:
|
||||
if contract_amount is None or count < 1:
|
||||
return []
|
||||
total_cents = int(round(float(contract_amount) * 100))
|
||||
base_cents = total_cents // count
|
||||
amounts: List[Decimal] = []
|
||||
sum_cents = 0
|
||||
for i in range(count):
|
||||
if i == count - 1:
|
||||
cents = total_cents - sum_cents
|
||||
else:
|
||||
cents = base_cents
|
||||
sum_cents += base_cents
|
||||
amounts.append(Decimal(cents) / Decimal(100))
|
||||
return amounts
|
||||
|
||||
|
||||
def align_fq_payment_details_payload(
|
||||
pay: BizProtocolPayment,
|
||||
details: Optional[list],
|
||||
) -> list:
|
||||
if pay.pay_mode != "fq" or not pay.installment_count or int(pay.installment_count) < 1:
|
||||
return list(details or [])
|
||||
count = int(pay.installment_count)
|
||||
rows = [_normalize_fields(dict(d)) for d in (details or [])]
|
||||
if len(rows) >= count:
|
||||
needed = set(range(1, count + 1))
|
||||
have = {
|
||||
int(r["installment_no"])
|
||||
for r in rows
|
||||
if r.get("installment_no") is not None
|
||||
}
|
||||
if needed - have == set():
|
||||
return rows[:count]
|
||||
|
||||
by_no: Dict[int, Dict[str, Any]] = {}
|
||||
unnumbered: List[Dict[str, Any]] = []
|
||||
for r in rows:
|
||||
no = r.get("installment_no")
|
||||
if no is not None:
|
||||
by_no[int(no)] = r
|
||||
else:
|
||||
unnumbered.append(r)
|
||||
|
||||
amounts = _calc_installment_due_amounts(pay.contract_amount, count)
|
||||
start = (pay.installment_start_date or "").strip()
|
||||
interval = int(pay.installment_interval_months or 0)
|
||||
result: List[Dict[str, Any]] = []
|
||||
for i in range(count):
|
||||
no = i + 1
|
||||
if no in by_no:
|
||||
result.append(by_no[no])
|
||||
continue
|
||||
if i < len(unnumbered):
|
||||
raw = dict(unnumbered[i])
|
||||
raw["installment_no"] = no
|
||||
if not raw.get("installment_due_date") and start and interval:
|
||||
raw["installment_due_date"] = _add_months_yyyymmdd(start, i * interval)
|
||||
result.append(raw)
|
||||
continue
|
||||
due_date = _add_months_yyyymmdd(start, i * interval) if start and interval else ""
|
||||
due = amounts[i] if i < len(amounts) else None
|
||||
result.append(
|
||||
{
|
||||
"installment_no": no,
|
||||
"installment_due_date": due_date or None,
|
||||
"due_amount": float(due) if due is not None else None,
|
||||
"pay_status": "dfj",
|
||||
"transfer_type": "",
|
||||
"transfer_amount": None,
|
||||
"attachments": [],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def list_payment_details(
|
||||
db: Session,
|
||||
payment_id: str,
|
||||
@@ -1791,6 +1948,7 @@ def list_payment_details(
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
BizProtocolPaymentDetail.installment_no.asc(),
|
||||
BizProtocolPaymentDetail.order_num.asc(),
|
||||
BizProtocolPaymentDetail.create_time.asc(),
|
||||
)
|
||||
@@ -1875,17 +2033,43 @@ def save_payment_details(
|
||||
if existing:
|
||||
db.flush()
|
||||
|
||||
_STR_FIELDS = ["transfer_type", "pay_status"]
|
||||
_STR_FIELDS = [
|
||||
"transfer_type",
|
||||
"pay_status",
|
||||
"finance_remark",
|
||||
"refund_finance_remark",
|
||||
"refund_remark",
|
||||
]
|
||||
_STR_DATE_FIELDS = ["installment_due_date"]
|
||||
_DEC_FIELDS = ["due_amount", "transfer_amount"]
|
||||
_DEC_FIELDS = ["due_amount", "transfer_amount", "refund_amount"]
|
||||
_INT_FIELDS = ["installment_no"]
|
||||
result: List[BizProtocolPaymentDetail] = []
|
||||
for idx, raw in enumerate(details or []):
|
||||
raw = _normalize_fields(raw)
|
||||
att_src = raw.pop("attachments", None)
|
||||
if att_src is None:
|
||||
att_src = raw.get("transfer_attachments")
|
||||
transfer_att = attachments_to_urls(att_src)
|
||||
raw = _normalize_fields(dict(raw))
|
||||
transfer_att = _pop_attachment_urls(
|
||||
raw, "attachments", "transfer_attachments", "transferAttachments"
|
||||
)
|
||||
finance_confirm_att = _pop_attachment_urls(
|
||||
raw,
|
||||
"finance_confirm_attachments",
|
||||
"financeConfirmAttachments",
|
||||
"finance_confirm_attachment_files",
|
||||
"financeConfirmAttachmentFiles",
|
||||
)
|
||||
refund_att = _pop_attachment_urls(
|
||||
raw,
|
||||
"refund_attachments",
|
||||
"refundAttachments",
|
||||
"refund_attachment_files",
|
||||
"refundAttachmentFiles",
|
||||
)
|
||||
refund_finance_att = _pop_attachment_urls(
|
||||
raw,
|
||||
"refund_finance_confirm_attachments",
|
||||
"refundFinanceConfirmAttachments",
|
||||
"refund_finance_confirm_attachment_files",
|
||||
"refundFinanceConfirmAttachmentFiles",
|
||||
)
|
||||
|
||||
transfer_time = raw.get("transfer_time")
|
||||
if isinstance(transfer_time, str):
|
||||
@@ -1908,6 +2092,9 @@ def save_payment_details(
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
transfer_attachments=transfer_att,
|
||||
finance_confirm_attachments=finance_confirm_att,
|
||||
refund_attachments=refund_att,
|
||||
refund_finance_confirm_attachments=refund_finance_att,
|
||||
transfer_time=transfer_time,
|
||||
installment_no=installment_no,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.qc_param import BizQcItemParam
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcItemParam) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"paramName": r.param_name,
|
||||
"paramCode": r.param_code,
|
||||
"paramUnit": r.param_unit or "",
|
||||
"paramType": r.param_type or "text",
|
||||
"requiredFlag": r.required_flag or "0",
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def list_params_by_item(
|
||||
db: Session, item_source: str, item_id: str, tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcItemParam)
|
||||
.filter(
|
||||
BizQcItemParam.item_source == item_source,
|
||||
BizQcItemParam.item_id == item_id,
|
||||
BizQcItemParam.tenant_no == tenant_no,
|
||||
BizQcItemParam.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcItemParam.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def save_params_for_item(
|
||||
db: Session,
|
||||
item_source: str,
|
||||
item_id: str,
|
||||
params: List[Dict[str, Any]],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
now = datetime.now()
|
||||
db.query(BizQcItemParam).filter(
|
||||
BizQcItemParam.item_source == item_source,
|
||||
BizQcItemParam.item_id == item_id,
|
||||
BizQcItemParam.tenant_no == tenant_no,
|
||||
BizQcItemParam.del_flag == 0,
|
||||
).update(
|
||||
{"del_flag": 1, "update_by": user_id, "update_time": now},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
for idx, p in enumerate(params):
|
||||
obj = BizQcItemParam(
|
||||
id=generate_uuid(),
|
||||
item_source=item_source,
|
||||
item_id=item_id,
|
||||
param_name=p.get("paramName", ""),
|
||||
param_code=p.get("paramCode", ""),
|
||||
param_unit=p.get("paramUnit") or None,
|
||||
param_type=p.get("paramType", "text"),
|
||||
required_flag=p.get("requiredFlag", "0"),
|
||||
tenant_no=tenant_no,
|
||||
order_num=p.get("orderNum", idx),
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
db.commit()
|
||||
return list_params_by_item(db, item_source, item_id, tenant_no)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""实验室自检项目 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
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 BizQcLabTestItem
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcLabTestItem) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"itemCode": r.item_code,
|
||||
"itemName": r.item_name,
|
||||
"description": r.description or "",
|
||||
"status": r.status or "1",
|
||||
"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 item_code_exists(
|
||||
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
c = (code or "").strip()
|
||||
if not c:
|
||||
return False
|
||||
q = db.query(BizQcLabTestItem).filter(
|
||||
BizQcLabTestItem.item_code == c,
|
||||
BizQcLabTestItem.tenant_no == tenant_no,
|
||||
BizQcLabTestItem.del_flag == 0,
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizQcLabTestItem.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def list_lab_items(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizQcLabTestItem).filter(BizQcLabTestItem.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizQcLabTestItem.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(BizQcLabTestItem.item_code.like(like), BizQcLabTestItem.item_name.like(like))
|
||||
)
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q0 = q0.filter(BizQcLabTestItem.status == st)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcLabTestItem.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcLabTestItem.order_num.asc(), BizQcLabTestItem.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_active_lab_items(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcLabTestItem)
|
||||
.filter(
|
||||
BizQcLabTestItem.tenant_no == tenant_no,
|
||||
BizQcLabTestItem.del_flag == 0,
|
||||
BizQcLabTestItem.status == "1",
|
||||
)
|
||||
.order_by(BizQcLabTestItem.order_num.asc(), BizQcLabTestItem.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
def get_lab_item(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizQcLabTestItem]:
|
||||
q = db.query(BizQcLabTestItem).filter(
|
||||
BizQcLabTestItem.id == row_id, BizQcLabTestItem.del_flag == 0
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizQcLabTestItem.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_lab_item(
|
||||
db: Session,
|
||||
*,
|
||||
item_code: str,
|
||||
item_name: str,
|
||||
description: Optional[str] = None,
|
||||
status: str = "1",
|
||||
tenant_no: str,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizQcLabTestItem:
|
||||
code = (item_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("项目编码不能为空")
|
||||
if item_code_exists(db, code, tenant_no):
|
||||
raise ValueError("项目编码已存在")
|
||||
name = (item_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("项目名称不能为空")
|
||||
st = (status or "1").strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj = BizQcLabTestItem(
|
||||
id=generate_uuid(),
|
||||
item_code=code,
|
||||
item_name=name,
|
||||
description=(description or "").strip() or None,
|
||||
status=st,
|
||||
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_dept_id or "",
|
||||
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_lab_item(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
item_code: Optional[str] = None,
|
||||
item_name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizQcLabTestItem:
|
||||
obj = get_lab_item(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("项目不存在")
|
||||
if item_code is not None:
|
||||
code = item_code.strip()
|
||||
if not code:
|
||||
raise ValueError("项目编码不能为空")
|
||||
if code != obj.item_code and item_code_exists(db, code, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("项目编码已存在")
|
||||
obj.item_code = code
|
||||
if item_name is not None:
|
||||
name = (item_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("项目名称不能为空")
|
||||
obj.item_name = name
|
||||
if description is not None:
|
||||
obj.description = (description or "").strip() or None
|
||||
if status is not None:
|
||||
st = status.strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj.status = st
|
||||
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_lab_item(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_lab_item(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("项目不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
@@ -0,0 +1,266 @@
|
||||
"""质检任务 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import BizQcTask, BizQcWorkOrder
|
||||
|
||||
|
||||
def _dec(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
return float(v)
|
||||
|
||||
|
||||
def _fmt_dt(t: Optional[datetime]) -> str:
|
||||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||||
|
||||
|
||||
def _gen_task_no(db: Session, work_order: BizQcWorkOrder, seq: int) -> str:
|
||||
return f"{work_order.work_order_no}-{seq:02d}"
|
||||
|
||||
|
||||
def _task_to_dict(t: BizQcTask, work_order_no: str = "") -> Dict[str, Any]:
|
||||
return {
|
||||
"id": t.id,
|
||||
"workOrderId": t.work_order_id,
|
||||
"workOrderNo": work_order_no,
|
||||
"taskNo": t.task_no,
|
||||
"taskType": t.task_type,
|
||||
"sourceLineId": t.source_line_id,
|
||||
"itemCode": t.item_code,
|
||||
"itemName": t.item_name,
|
||||
"companyId": t.company_id or "",
|
||||
"companyName": t.company_name or "",
|
||||
"feeAmount": _dec(t.fee_amount),
|
||||
"taskStatus": t.task_status,
|
||||
"thirdPartyAttachments": t.third_party_attachments or "",
|
||||
"completedTime": _fmt_dt(t.completed_time),
|
||||
"completedBy": t.completed_by or "",
|
||||
"remark": t.remark or "",
|
||||
"createTime": _fmt_dt(t.create_time),
|
||||
"updateTime": _fmt_dt(t.update_time),
|
||||
}
|
||||
|
||||
|
||||
def create_task_for_line(
|
||||
db: Session,
|
||||
*,
|
||||
work_order: BizQcWorkOrder,
|
||||
task_type: str,
|
||||
source_line_id: str,
|
||||
item_code: str,
|
||||
item_name: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
seq: int = 1,
|
||||
company_id: Optional[str] = None,
|
||||
company_name: Optional[str] = None,
|
||||
fee_amount: Optional[Decimal] = None,
|
||||
) -> BizQcTask:
|
||||
obj = BizQcTask(
|
||||
id=generate_uuid(),
|
||||
work_order_id=work_order.id,
|
||||
task_no=_gen_task_no(db, work_order, seq),
|
||||
task_type=task_type,
|
||||
source_line_id=source_line_id,
|
||||
item_code=item_code,
|
||||
item_name=item_name,
|
||||
company_id=company_id,
|
||||
company_name=company_name,
|
||||
fee_amount=fee_amount,
|
||||
task_status="dzx",
|
||||
tenant_no=tenant_no,
|
||||
order_num=seq,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
def list_tasks(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
task_type: Optional[str] = None,
|
||||
task_status: Optional[str] = None,
|
||||
work_order_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = (
|
||||
db.query(BizQcTask, BizQcWorkOrder.work_order_no)
|
||||
.join(
|
||||
BizQcWorkOrder,
|
||||
and_(
|
||||
BizQcWorkOrder.id == BizQcTask.work_order_id,
|
||||
BizQcWorkOrder.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.filter(BizQcTask.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizQcTask.tenant_no == tenant_no)
|
||||
if work_order_id:
|
||||
q0 = q0.filter(BizQcTask.work_order_id == work_order_id)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizQcTask.task_no.like(like),
|
||||
BizQcTask.item_name.like(like),
|
||||
BizQcWorkOrder.work_order_no.like(like),
|
||||
)
|
||||
)
|
||||
tt = (task_type or "").strip()
|
||||
if tt:
|
||||
q0 = q0.filter(BizQcTask.task_type == tt)
|
||||
st = (task_status or "").strip()
|
||||
if st:
|
||||
q0 = q0.filter(BizQcTask.task_status == st)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcTask.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(
|
||||
BizQcTask.create_time.desc(),
|
||||
BizQcWorkOrder.work_order_no.desc(),
|
||||
BizQcTask.task_no.asc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_task_to_dict(t, wno or "") for t, wno in rows], int(total)
|
||||
|
||||
|
||||
def list_tasks_by_work_order(db: Session, work_order_id: str, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcTask, BizQcWorkOrder.work_order_no)
|
||||
.join(BizQcWorkOrder, BizQcWorkOrder.id == BizQcTask.work_order_id)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcTask.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
return [_task_to_dict(t, wno or "") for t, wno in rows]
|
||||
|
||||
|
||||
def get_task(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizQcTask]:
|
||||
q = db.query(BizQcTask).filter(BizQcTask.id == row_id, BizQcTask.del_flag == 0)
|
||||
if tenant_no:
|
||||
q = q.filter(BizQcTask.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def get_task_detail(db: Session, row_id: str, tenant_no: str) -> Dict[str, Any]:
|
||||
row = (
|
||||
db.query(BizQcTask, BizQcWorkOrder)
|
||||
.join(BizQcWorkOrder, BizQcWorkOrder.id == BizQcTask.work_order_id)
|
||||
.filter(BizQcTask.id == row_id, BizQcTask.tenant_no == tenant_no, BizQcTask.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
raise ValueError("任务不存在")
|
||||
t, wo = row
|
||||
out = _task_to_dict(t, wo.work_order_no or "")
|
||||
out["protocolNo"] = wo.protocol_no or ""
|
||||
out["sampleCode"] = wo.sample_code or ""
|
||||
out["attachmentFiles"] = urls_to_attachment_list(t.third_party_attachments, db, tenant_no)
|
||||
return out
|
||||
|
||||
|
||||
def start_task(db: Session, row_id: str, *, tenant_no: str, user_id: Optional[str]) -> BizQcTask:
|
||||
t = get_task(db, row_id, tenant_no=tenant_no)
|
||||
if not t:
|
||||
raise ValueError("任务不存在")
|
||||
if t.task_type != "sysz":
|
||||
raise ValueError("仅实验室自检任务可开始")
|
||||
if t.task_status != "dzx":
|
||||
raise ValueError("当前状态不可开始")
|
||||
t.task_status = "jxz"
|
||||
t.update_by = user_id
|
||||
t.update_time = datetime.now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
def complete_task(db: Session, row_id: str, *, tenant_no: str, user_id: Optional[str]) -> BizQcTask:
|
||||
t = get_task(db, row_id, tenant_no=tenant_no)
|
||||
if not t:
|
||||
raise ValueError("任务不存在")
|
||||
if t.task_type == "sysz":
|
||||
if t.task_status not in ("dzx", "jxz"):
|
||||
raise ValueError("当前状态不可完成")
|
||||
elif t.task_type == "dsfjc":
|
||||
if not (t.third_party_attachments or "").strip():
|
||||
raise ValueError("请先上传第三方检测材料")
|
||||
if t.task_status == "ywc":
|
||||
raise ValueError("任务已完成")
|
||||
else:
|
||||
raise ValueError("任务类型无效")
|
||||
t.task_status = "ywc"
|
||||
t.completed_time = datetime.now()
|
||||
t.completed_by = user_id
|
||||
t.update_by = user_id
|
||||
t.update_time = datetime.now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
from app.crud import qc_work_order as wo_svc
|
||||
|
||||
wo_svc.try_complete_work_order(db, t.work_order_id, tenant_no)
|
||||
return t
|
||||
|
||||
|
||||
def update_task_attachments(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
attachments: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
) -> BizQcTask:
|
||||
t = get_task(db, row_id, tenant_no=tenant_no)
|
||||
if not t:
|
||||
raise ValueError("任务不存在")
|
||||
if t.task_type != "dsfjc":
|
||||
raise ValueError("仅第三方检测任务可上传材料")
|
||||
if t.task_status == "ywc":
|
||||
raise ValueError("任务已完成,不可修改附件")
|
||||
t.third_party_attachments = (attachments or "").strip() or None
|
||||
if t.task_status == "dzx" and t.third_party_attachments:
|
||||
t.task_status = "jxz"
|
||||
t.update_by = user_id
|
||||
t.update_time = datetime.now()
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.qc_param import BizQcTaskResult
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcTaskResult) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"paramId": r.param_id or "",
|
||||
"paramName": r.param_name,
|
||||
"paramCode": r.param_code or "",
|
||||
"paramUnit": r.param_unit or "",
|
||||
"paramType": r.param_type or "text",
|
||||
"paramValue": r.param_value or "",
|
||||
}
|
||||
|
||||
|
||||
def list_results_by_task(
|
||||
db: Session, task_id: str, tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcTaskResult)
|
||||
.filter(
|
||||
BizQcTaskResult.task_id == task_id,
|
||||
BizQcTaskResult.tenant_no == tenant_no,
|
||||
BizQcTaskResult.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcTaskResult.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
def save_results_for_task(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
results: List[Dict[str, Any]],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
now = datetime.now()
|
||||
db.query(BizQcTaskResult).filter(
|
||||
BizQcTaskResult.task_id == task_id,
|
||||
BizQcTaskResult.tenant_no == tenant_no,
|
||||
BizQcTaskResult.del_flag == 0,
|
||||
).update(
|
||||
{"del_flag": 1, "update_by": user_id, "update_time": now},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
db.flush()
|
||||
|
||||
for res in results:
|
||||
obj = BizQcTaskResult(
|
||||
id=generate_uuid(),
|
||||
task_id=task_id,
|
||||
param_id=res.get("paramId") or None,
|
||||
param_name=res.get("paramName", ""),
|
||||
param_code=res.get("paramCode") or None,
|
||||
param_unit=res.get("paramUnit") or None,
|
||||
param_type=res.get("paramType", "text"),
|
||||
param_value=res.get("paramValue") or None,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
db.commit()
|
||||
return list_results_by_task(db, task_id, tenant_no)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""第三方检测公司 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
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 BizQcThirdPartyCompany, BizQcThirdPartyItem
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcThirdPartyCompany) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"companyCode": r.company_code,
|
||||
"companyName": r.company_name,
|
||||
"contactName": r.contact_name or "",
|
||||
"contactPhone": r.contact_phone or "",
|
||||
"address": r.address or "",
|
||||
"invoiceTitle": r.invoice_title or "",
|
||||
"taxId": r.tax_id or "",
|
||||
"bankName": r.bank_name or "",
|
||||
"bankAccount": r.bank_account or "",
|
||||
"status": r.status or "1",
|
||||
"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 company_code_exists(
|
||||
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
c = (code or "").strip()
|
||||
if not c:
|
||||
return False
|
||||
q = db.query(BizQcThirdPartyCompany).filter(
|
||||
BizQcThirdPartyCompany.company_code == c,
|
||||
BizQcThirdPartyCompany.tenant_no == tenant_no,
|
||||
BizQcThirdPartyCompany.del_flag == 0,
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizQcThirdPartyCompany.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def list_companies(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizQcThirdPartyCompany).filter(BizQcThirdPartyCompany.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizQcThirdPartyCompany.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizQcThirdPartyCompany.company_code.like(like),
|
||||
BizQcThirdPartyCompany.company_name.like(like),
|
||||
)
|
||||
)
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q0 = q0.filter(BizQcThirdPartyCompany.status == st)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcThirdPartyCompany.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcThirdPartyCompany.order_num.asc(), BizQcThirdPartyCompany.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_company_options(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcThirdPartyCompany)
|
||||
.filter(
|
||||
BizQcThirdPartyCompany.tenant_no == tenant_no,
|
||||
BizQcThirdPartyCompany.del_flag == 0,
|
||||
BizQcThirdPartyCompany.status == "1",
|
||||
)
|
||||
.order_by(BizQcThirdPartyCompany.order_num.asc(), BizQcThirdPartyCompany.create_time.desc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"id": r.id, "companyCode": r.company_code, "companyName": r.company_name}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_company(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizQcThirdPartyCompany]:
|
||||
q = db.query(BizQcThirdPartyCompany).filter(
|
||||
BizQcThirdPartyCompany.id == row_id, BizQcThirdPartyCompany.del_flag == 0
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizQcThirdPartyCompany.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_company(
|
||||
db: Session,
|
||||
*,
|
||||
company_code: str,
|
||||
company_name: str,
|
||||
contact_name: Optional[str] = None,
|
||||
contact_phone: Optional[str] = None,
|
||||
address: Optional[str] = None,
|
||||
invoice_title: Optional[str] = None,
|
||||
tax_id: Optional[str] = None,
|
||||
bank_name: Optional[str] = None,
|
||||
bank_account: Optional[str] = None,
|
||||
status: str = "1",
|
||||
tenant_no: str,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizQcThirdPartyCompany:
|
||||
code = (company_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("公司编码不能为空")
|
||||
if company_code_exists(db, code, tenant_no):
|
||||
raise ValueError("公司编码已存在")
|
||||
name = (company_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("公司名称不能为空")
|
||||
st = (status or "1").strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj = BizQcThirdPartyCompany(
|
||||
id=generate_uuid(),
|
||||
company_code=code,
|
||||
company_name=name,
|
||||
contact_name=(contact_name or "").strip() or None,
|
||||
contact_phone=(contact_phone or "").strip() or None,
|
||||
address=(address or "").strip() or None,
|
||||
invoice_title=(invoice_title or "").strip() or None,
|
||||
tax_id=(tax_id or "").strip() or None,
|
||||
bank_name=(bank_name or "").strip() or None,
|
||||
bank_account=(bank_account or "").strip() or None,
|
||||
status=st,
|
||||
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_dept_id or "",
|
||||
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_company(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
company_code: Optional[str] = None,
|
||||
company_name: Optional[str] = None,
|
||||
contact_name: Optional[str] = None,
|
||||
contact_phone: Optional[str] = None,
|
||||
address: Optional[str] = None,
|
||||
invoice_title: Optional[str] = None,
|
||||
tax_id: Optional[str] = None,
|
||||
bank_name: Optional[str] = None,
|
||||
bank_account: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizQcThirdPartyCompany:
|
||||
obj = get_company(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("公司不存在")
|
||||
if company_code is not None:
|
||||
code = company_code.strip()
|
||||
if not code:
|
||||
raise ValueError("公司编码不能为空")
|
||||
if code != obj.company_code and company_code_exists(db, code, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("公司编码已存在")
|
||||
obj.company_code = code
|
||||
if company_name is not None:
|
||||
name = (company_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("公司名称不能为空")
|
||||
obj.company_name = name
|
||||
if contact_name is not None:
|
||||
obj.contact_name = (contact_name or "").strip() or None
|
||||
if contact_phone is not None:
|
||||
obj.contact_phone = (contact_phone or "").strip() or None
|
||||
if address is not None:
|
||||
obj.address = (address or "").strip() or None
|
||||
if invoice_title is not None:
|
||||
obj.invoice_title = (invoice_title or "").strip() or None
|
||||
if tax_id is not None:
|
||||
obj.tax_id = (tax_id or "").strip() or None
|
||||
if bank_name is not None:
|
||||
obj.bank_name = (bank_name or "").strip() or None
|
||||
if bank_account is not None:
|
||||
obj.bank_account = (bank_account or "").strip() or None
|
||||
if status is not None:
|
||||
st = status.strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj.status = st
|
||||
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_company(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_company(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("公司不存在")
|
||||
child = (
|
||||
db.query(BizQcThirdPartyItem)
|
||||
.filter(
|
||||
BizQcThirdPartyItem.company_id == row_id,
|
||||
BizQcThirdPartyItem.tenant_no == obj.tenant_no,
|
||||
BizQcThirdPartyItem.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if child:
|
||||
raise ValueError("该公司下仍有检测项目,无法删除")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
@@ -0,0 +1,256 @@
|
||||
"""第三方检测项目 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from app.crud.qc_third_party_company import get_company
|
||||
from app.models.models import BizQcThirdPartyCompany, BizQcThirdPartyItem
|
||||
|
||||
|
||||
def _dec(v: Any) -> float:
|
||||
if v is None:
|
||||
return 0.0
|
||||
return float(v)
|
||||
|
||||
|
||||
def _row_to_dict(r: BizQcThirdPartyItem, company_name: str = "") -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"companyId": r.company_id,
|
||||
"companyName": company_name,
|
||||
"itemCode": r.item_code,
|
||||
"itemName": r.item_name,
|
||||
"feeAmount": _dec(r.fee_amount),
|
||||
"status": r.status or "1",
|
||||
"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 item_code_exists(
|
||||
db: Session,
|
||||
company_id: str,
|
||||
code: str,
|
||||
tenant_no: str,
|
||||
exclude_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
c = (code or "").strip()
|
||||
if not c:
|
||||
return False
|
||||
q = db.query(BizQcThirdPartyItem).filter(
|
||||
BizQcThirdPartyItem.company_id == company_id,
|
||||
BizQcThirdPartyItem.item_code == c,
|
||||
BizQcThirdPartyItem.tenant_no == tenant_no,
|
||||
BizQcThirdPartyItem.del_flag == 0,
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizQcThirdPartyItem.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def _company_name_map(db: Session, company_ids: List[str]) -> Dict[str, str]:
|
||||
if not company_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(BizQcThirdPartyCompany)
|
||||
.filter(BizQcThirdPartyCompany.id.in_(company_ids), BizQcThirdPartyCompany.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
return {r.id: r.company_name for r in rows}
|
||||
|
||||
|
||||
def list_items(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
company_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizQcThirdPartyItem).filter(BizQcThirdPartyItem.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizQcThirdPartyItem.tenant_no == tenant_no)
|
||||
if company_id:
|
||||
q0 = q0.filter(BizQcThirdPartyItem.company_id == company_id)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizQcThirdPartyItem.item_code.like(like),
|
||||
BizQcThirdPartyItem.item_name.like(like),
|
||||
)
|
||||
)
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q0 = q0.filter(BizQcThirdPartyItem.status == st)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcThirdPartyItem.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcThirdPartyItem.order_num.asc(), BizQcThirdPartyItem.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
cids = list({r.company_id for r in rows})
|
||||
cmap = _company_name_map(db, cids)
|
||||
return [_row_to_dict(x, cmap.get(x.company_id, "")) for x in rows], int(total)
|
||||
|
||||
|
||||
def list_active_items(
|
||||
db: Session, tenant_no: str, company_id: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
q = db.query(BizQcThirdPartyItem).filter(
|
||||
BizQcThirdPartyItem.tenant_no == tenant_no,
|
||||
BizQcThirdPartyItem.del_flag == 0,
|
||||
BizQcThirdPartyItem.status == "1",
|
||||
)
|
||||
if company_id:
|
||||
q = q.filter(BizQcThirdPartyItem.company_id == company_id)
|
||||
rows = q.order_by(
|
||||
BizQcThirdPartyItem.order_num.asc(), BizQcThirdPartyItem.create_time.desc()
|
||||
).all()
|
||||
cmap = _company_name_map(db, list({r.company_id for r in rows}))
|
||||
return [_row_to_dict(x, cmap.get(x.company_id, "")) for x in rows]
|
||||
|
||||
|
||||
def get_item(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizQcThirdPartyItem]:
|
||||
q = db.query(BizQcThirdPartyItem).filter(
|
||||
BizQcThirdPartyItem.id == row_id, BizQcThirdPartyItem.del_flag == 0
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizQcThirdPartyItem.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_item(
|
||||
db: Session,
|
||||
*,
|
||||
company_id: str,
|
||||
item_code: str,
|
||||
item_name: str,
|
||||
fee_amount: float,
|
||||
status: str = "1",
|
||||
tenant_no: str,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizQcThirdPartyItem:
|
||||
comp = get_company(db, company_id, tenant_no=tenant_no)
|
||||
if not comp:
|
||||
raise ValueError("所属公司不存在")
|
||||
code = (item_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("项目编码不能为空")
|
||||
if item_code_exists(db, company_id, code, tenant_no):
|
||||
raise ValueError("该公司下项目编码已存在")
|
||||
name = (item_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("项目名称不能为空")
|
||||
st = (status or "1").strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
fee = Decimal(str(fee_amount if fee_amount is not None else 0))
|
||||
obj = BizQcThirdPartyItem(
|
||||
id=generate_uuid(),
|
||||
company_id=company_id,
|
||||
item_code=code,
|
||||
item_name=name,
|
||||
fee_amount=fee,
|
||||
status=st,
|
||||
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_dept_id or "",
|
||||
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_item(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
company_id: Optional[str] = None,
|
||||
item_code: Optional[str] = None,
|
||||
item_name: Optional[str] = None,
|
||||
fee_amount: Optional[float] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizQcThirdPartyItem:
|
||||
obj = get_item(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("项目不存在")
|
||||
cid = company_id or obj.company_id
|
||||
if company_id is not None:
|
||||
comp = get_company(db, company_id, tenant_no=obj.tenant_no)
|
||||
if not comp:
|
||||
raise ValueError("所属公司不存在")
|
||||
obj.company_id = company_id
|
||||
if item_code is not None:
|
||||
code = item_code.strip()
|
||||
if not code:
|
||||
raise ValueError("项目编码不能为空")
|
||||
if code != obj.item_code and item_code_exists(db, cid, code, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("该公司下项目编码已存在")
|
||||
obj.item_code = code
|
||||
if item_name is not None:
|
||||
name = (item_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("项目名称不能为空")
|
||||
obj.item_name = name
|
||||
if fee_amount is not None:
|
||||
obj.fee_amount = Decimal(str(fee_amount))
|
||||
if status is not None:
|
||||
st = status.strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj.status = st
|
||||
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_item(db: Session, row_id: str, tenant_no: Optional[str] = None) -> None:
|
||||
obj = get_item(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("项目不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
@@ -0,0 +1,423 @@
|
||||
"""质检工单 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from app.crud import qc_lab_test_item as lab_item_svc
|
||||
from app.crud import qc_task as task_svc
|
||||
from app.crud import qc_third_party_item as third_item_svc
|
||||
from app.crud.qc_third_party_company import get_company
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolSample,
|
||||
BizQcLabTestItem,
|
||||
BizQcTask,
|
||||
BizQcThirdPartyItem,
|
||||
BizQcWorkOrder,
|
||||
BizQcWorkOrderLabItem,
|
||||
BizQcWorkOrderThirdItem,
|
||||
BizSampleReception,
|
||||
)
|
||||
|
||||
|
||||
def _dec(v: Any) -> Optional[float]:
|
||||
if v is None:
|
||||
return None
|
||||
return float(v)
|
||||
|
||||
|
||||
def _fmt_dt(t: Optional[datetime]) -> str:
|
||||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||||
|
||||
|
||||
def _gen_daily_no(db: Session, tenant_no: str, prefix: str) -> str:
|
||||
day = datetime.now().strftime("%Y%m%d")
|
||||
like = f"{prefix}{day}%"
|
||||
cnt = (
|
||||
db.query(BizQcWorkOrder)
|
||||
.filter(
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
BizQcWorkOrder.work_order_no.like(like),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
return f"{prefix}{day}{cnt + 1:04d}"
|
||||
|
||||
|
||||
def _wo_to_dict(wo: BizQcWorkOrder) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": wo.id,
|
||||
"workOrderNo": wo.work_order_no,
|
||||
"receptionId": wo.reception_id,
|
||||
"protocolSampleId": wo.protocol_sample_id,
|
||||
"collectionProtocolId": wo.collection_protocol_id,
|
||||
"protocolNo": wo.protocol_no or "",
|
||||
"sampleCode": wo.sample_code or "",
|
||||
"workOrderStatus": wo.work_order_status,
|
||||
"hasLab": wo.has_lab == "1",
|
||||
"hasThirdParty": wo.has_third_party == "1",
|
||||
"configuredTime": _fmt_dt(wo.configured_time),
|
||||
"configuredBy": wo.configured_by or "",
|
||||
"thirdPartyFeeTotal": _dec(wo.third_party_fee_total),
|
||||
"remark": wo.remark or "",
|
||||
"createTime": _fmt_dt(wo.create_time),
|
||||
"updateTime": _fmt_dt(wo.update_time),
|
||||
}
|
||||
|
||||
|
||||
def create_work_order_from_reception(
|
||||
db: Session,
|
||||
*,
|
||||
reception: BizSampleReception,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> Optional[BizQcWorkOrder]:
|
||||
"""接收结论为 js 时创建待配置工单(flush,不 commit)。"""
|
||||
if (reception.accept_conclusion or "").strip() != "js":
|
||||
return None
|
||||
exists = (
|
||||
db.query(BizQcWorkOrder)
|
||||
.filter(
|
||||
BizQcWorkOrder.reception_id == reception.id,
|
||||
BizQcWorkOrder.tenant_no == tenant_no,
|
||||
BizQcWorkOrder.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
return exists
|
||||
protocol_no = ""
|
||||
p = (
|
||||
db.query(BizCollectionProtocol)
|
||||
.filter(
|
||||
BizCollectionProtocol.id == reception.collection_protocol_id,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if p:
|
||||
protocol_no = p.protocol_no or ""
|
||||
sample_code = ""
|
||||
s = (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(BizProtocolSample.id == reception.protocol_sample_id, BizProtocolSample.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
if s:
|
||||
sample_code = s.sample_code or ""
|
||||
wo = BizQcWorkOrder(
|
||||
id=generate_uuid(),
|
||||
work_order_no=_gen_daily_no(db, tenant_no, "ZJ"),
|
||||
reception_id=reception.id,
|
||||
protocol_sample_id=reception.protocol_sample_id,
|
||||
collection_protocol_id=reception.collection_protocol_id,
|
||||
protocol_no=protocol_no or None,
|
||||
sample_code=sample_code or None,
|
||||
work_order_status="dpz",
|
||||
has_lab="0",
|
||||
has_third_party="0",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
update_by=user_id,
|
||||
create_time=datetime.now(),
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(wo)
|
||||
db.flush()
|
||||
return wo
|
||||
|
||||
|
||||
def list_work_orders(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
work_order_status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizQcWorkOrder).filter(BizQcWorkOrder.del_flag == 0)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizQcWorkOrder.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizQcWorkOrder.work_order_no.like(like),
|
||||
BizQcWorkOrder.protocol_no.like(like),
|
||||
BizQcWorkOrder.sample_code.like(like),
|
||||
)
|
||||
)
|
||||
st = (work_order_status or "").strip()
|
||||
if st:
|
||||
q0 = q0.filter(BizQcWorkOrder.work_order_status == st)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(func.count(BizQcWorkOrder.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
_filtered()
|
||||
.order_by(BizQcWorkOrder.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [_wo_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def get_work_order(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizQcWorkOrder]:
|
||||
q = db.query(BizQcWorkOrder).filter(BizQcWorkOrder.id == row_id, BizQcWorkOrder.del_flag == 0)
|
||||
if tenant_no:
|
||||
q = q.filter(BizQcWorkOrder.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def _lab_lines(db: Session, work_order_id: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcWorkOrderLabItem)
|
||||
.filter(
|
||||
BizQcWorkOrderLabItem.work_order_id == work_order_id,
|
||||
BizQcWorkOrderLabItem.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcWorkOrderLabItem.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"labTestItemId": r.lab_test_item_id,
|
||||
"itemCode": r.item_code,
|
||||
"itemName": r.item_name,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _third_lines(db: Session, work_order_id: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizQcWorkOrderThirdItem)
|
||||
.filter(
|
||||
BizQcWorkOrderThirdItem.work_order_id == work_order_id,
|
||||
BizQcWorkOrderThirdItem.del_flag == 0,
|
||||
)
|
||||
.order_by(BizQcWorkOrderThirdItem.order_num.asc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"thirdPartyItemId": r.third_party_item_id,
|
||||
"companyId": r.company_id,
|
||||
"companyName": r.company_name,
|
||||
"itemCode": r.item_code,
|
||||
"itemName": r.item_name,
|
||||
"feeAmount": _dec(r.fee_amount),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_work_order_detail(
|
||||
db: Session, row_id: str, tenant_no: str
|
||||
) -> Dict[str, Any]:
|
||||
wo = get_work_order(db, row_id, tenant_no=tenant_no)
|
||||
if not wo:
|
||||
raise ValueError("工单不存在")
|
||||
out = _wo_to_dict(wo)
|
||||
rec = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(BizSampleReception.id == wo.reception_id, BizSampleReception.del_flag == 0)
|
||||
.first()
|
||||
)
|
||||
out["reception"] = {
|
||||
"receptionTime": _fmt_dt(rec.reception_time) if rec else "",
|
||||
"handlerName": (rec.handler_name if rec else "") or "",
|
||||
"acceptConclusion": (rec.accept_conclusion if rec else "") or "",
|
||||
}
|
||||
out["labItems"] = _lab_lines(db, wo.id)
|
||||
out["thirdPartyItems"] = _third_lines(db, wo.id)
|
||||
out["tasks"] = task_svc.list_tasks_by_work_order(db, wo.id, tenant_no)
|
||||
return out
|
||||
|
||||
|
||||
def configure_work_order(
|
||||
db: Session,
|
||||
work_order_id: str,
|
||||
*,
|
||||
has_lab: bool,
|
||||
has_third_party: bool,
|
||||
lab_item_ids: List[str],
|
||||
third_party_item_ids: List[str],
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizQcWorkOrder:
|
||||
wo = get_work_order(db, work_order_id, tenant_no=tenant_no)
|
||||
if not wo:
|
||||
raise ValueError("工单不存在")
|
||||
if wo.work_order_status != "dpz":
|
||||
raise ValueError("仅待配置工单可配置检测项目")
|
||||
task_cnt = (
|
||||
db.query(BizQcTask)
|
||||
.filter(BizQcTask.work_order_id == wo.id, BizQcTask.del_flag == 0)
|
||||
.count()
|
||||
)
|
||||
if task_cnt:
|
||||
raise ValueError("工单已存在任务,无法重新配置")
|
||||
|
||||
if not has_lab and not has_third_party:
|
||||
raise ValueError("请至少选择一种检测方式")
|
||||
if has_lab and not lab_item_ids:
|
||||
raise ValueError("请选择实验室自检项目")
|
||||
if has_third_party and not third_party_item_ids:
|
||||
raise ValueError("请选择第三方检测项目")
|
||||
if not has_lab:
|
||||
lab_item_ids = []
|
||||
if not has_third_party:
|
||||
third_party_item_ids = []
|
||||
|
||||
# 清除旧明细
|
||||
for row in (
|
||||
db.query(BizQcWorkOrderLabItem)
|
||||
.filter(BizQcWorkOrderLabItem.work_order_id == wo.id, BizQcWorkOrderLabItem.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
row.del_flag = 1
|
||||
for row in (
|
||||
db.query(BizQcWorkOrderThirdItem)
|
||||
.filter(BizQcWorkOrderThirdItem.work_order_id == wo.id, BizQcWorkOrderThirdItem.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
row.del_flag = 1
|
||||
|
||||
fee_total = Decimal("0")
|
||||
seq = 0
|
||||
|
||||
for lid in lab_item_ids:
|
||||
item = lab_item_svc.get_lab_item(db, lid, tenant_no=tenant_no)
|
||||
if not item or item.status != "1":
|
||||
raise ValueError("实验室项目无效或已停用")
|
||||
seq += 1
|
||||
line = BizQcWorkOrderLabItem(
|
||||
id=generate_uuid(),
|
||||
work_order_id=wo.id,
|
||||
lab_test_item_id=item.id,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_name,
|
||||
tenant_no=tenant_no,
|
||||
order_num=seq,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(line)
|
||||
db.flush()
|
||||
task_svc.create_task_for_line(
|
||||
db,
|
||||
work_order=wo,
|
||||
task_type="sysz",
|
||||
source_line_id=line.id,
|
||||
item_code=line.item_code,
|
||||
item_name=line.item_name,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
seq=seq,
|
||||
)
|
||||
|
||||
for tid in third_party_item_ids:
|
||||
item = third_item_svc.get_item(db, tid, tenant_no=tenant_no)
|
||||
if not item or item.status != "1":
|
||||
raise ValueError("第三方项目无效或已停用")
|
||||
comp = get_company(db, item.company_id, tenant_no=tenant_no)
|
||||
if not comp:
|
||||
raise ValueError("第三方公司不存在")
|
||||
seq += 1
|
||||
fee = item.fee_amount or Decimal("0")
|
||||
fee_total += fee
|
||||
line = BizQcWorkOrderThirdItem(
|
||||
id=generate_uuid(),
|
||||
work_order_id=wo.id,
|
||||
third_party_item_id=item.id,
|
||||
company_id=comp.id,
|
||||
company_name=comp.company_name,
|
||||
item_code=item.item_code,
|
||||
item_name=item.item_name,
|
||||
fee_amount=fee,
|
||||
tenant_no=tenant_no,
|
||||
order_num=seq,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=datetime.now(),
|
||||
update_by=user_id,
|
||||
update_time=datetime.now(),
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(line)
|
||||
db.flush()
|
||||
task_svc.create_task_for_line(
|
||||
db,
|
||||
work_order=wo,
|
||||
task_type="dsfjc",
|
||||
source_line_id=line.id,
|
||||
item_code=line.item_code,
|
||||
item_name=line.item_name,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id,
|
||||
seq=seq,
|
||||
company_id=comp.id,
|
||||
company_name=comp.company_name,
|
||||
fee_amount=fee,
|
||||
)
|
||||
|
||||
wo.has_lab = "1" if has_lab else "0"
|
||||
wo.has_third_party = "1" if has_third_party else "0"
|
||||
wo.work_order_status = "ypz"
|
||||
wo.configured_time = datetime.now()
|
||||
wo.configured_by = user_id
|
||||
wo.third_party_fee_total = fee_total if has_third_party else None
|
||||
wo.update_by = user_id
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
db.commit()
|
||||
db.refresh(wo)
|
||||
return wo
|
||||
|
||||
|
||||
def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) -> None:
|
||||
pending = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
BizQcTask.task_status != "ywc",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if pending:
|
||||
return
|
||||
wo = get_work_order(db, work_order_id, tenant_no=tenant_no)
|
||||
if wo and wo.work_order_status == "ypz":
|
||||
wo.work_order_status = "ywc"
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
db.commit()
|
||||
@@ -0,0 +1,486 @@
|
||||
"""样本接收 CRUD(质量检测)"""
|
||||
from __future__ import annotations
|
||||
|
||||
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.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.crud import sample_reception_check_result as check_result_svc
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionProtocol,
|
||||
BizExpressCompany,
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolSample,
|
||||
BizProtocolSampleTransport,
|
||||
BizSampleReception,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
return float(v)
|
||||
|
||||
|
||||
def _reception_to_dict(
|
||||
r: BizSampleReception,
|
||||
*,
|
||||
check_results: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"protocolSampleId": r.protocol_sample_id,
|
||||
"collectionProtocolId": r.collection_protocol_id,
|
||||
"receptionTime": _fmt_dt(r.reception_time),
|
||||
"handlerUserId": r.handler_user_id or "",
|
||||
"handlerName": r.handler_name or "",
|
||||
"receiveTemperature": r.receive_temperature or "",
|
||||
"volumeMl": _dec(r.volume_ml),
|
||||
"netWeightG": _dec(r.net_weight_g),
|
||||
"checkResults": check_results or [],
|
||||
"acceptConclusion": r.accept_conclusion or "",
|
||||
"receiveAttachments": r.receive_attachments or "",
|
||||
"remark": r.remark or "",
|
||||
"createTime": _fmt_dt(r.create_time),
|
||||
"updateTime": _fmt_dt(r.update_time),
|
||||
}
|
||||
|
||||
|
||||
def _sample_detail_rows_query(db: Session):
|
||||
"""协议样本 + 协议单 + 客户 + 运输(显式 select_from 避免多表 join 歧义)。"""
|
||||
return (
|
||||
db.query(
|
||||
BizProtocolSample,
|
||||
BizCollectionProtocol,
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolSampleTransport,
|
||||
)
|
||||
.select_from(BizProtocolSample)
|
||||
.join(
|
||||
BizCollectionProtocol,
|
||||
and_(
|
||||
BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
BizProtocolPartyCustomer,
|
||||
and_(
|
||||
BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id,
|
||||
BizProtocolPartyCustomer.del_flag == 0,
|
||||
),
|
||||
)
|
||||
.outerjoin(
|
||||
BizProtocolSampleTransport,
|
||||
and_(
|
||||
BizProtocolSampleTransport.sample_id == BizProtocolSample.id,
|
||||
BizProtocolSampleTransport.del_flag == 0,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _sample_pending_query(db: Session, tenant_no: str, keyword: Optional[str] = None):
|
||||
"""待接收:协议样本且尚无有效接收记录。"""
|
||||
received_ids = (
|
||||
db.query(BizSampleReception.protocol_sample_id)
|
||||
.filter(
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
q = _sample_detail_rows_query(db).filter(
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
~BizProtocolSample.id.in_(received_ids),
|
||||
)
|
||||
if keyword and keyword.strip():
|
||||
kw = f"%{keyword.strip()}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
BizCollectionProtocol.protocol_no.like(kw),
|
||||
BizProtocolSample.sample_code.like(kw),
|
||||
BizProtocolPartyCustomer.mother_name.like(kw),
|
||||
BizProtocolPartyCustomer.newborn_name.like(kw),
|
||||
)
|
||||
)
|
||||
return q
|
||||
|
||||
|
||||
def _rows_to_sample_list_items(
|
||||
db: Session,
|
||||
rows: list,
|
||||
tenant_no: str,
|
||||
*,
|
||||
reception_map: Optional[Dict[str, BizSampleReception]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
type_ids = list({s.sample_type_id for s, *_ in rows if s.sample_type_id})
|
||||
type_map: Dict[str, str] = {}
|
||||
if type_ids:
|
||||
for t in (
|
||||
db.query(BizCellSampleType)
|
||||
.filter(
|
||||
BizCellSampleType.id.in_(type_ids),
|
||||
BizCellSampleType.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
):
|
||||
type_map[t.id] = t.type_name or ""
|
||||
|
||||
express_ids = list(
|
||||
{tr.express_company_id for *_, tr in rows if tr and tr.express_company_id}
|
||||
)
|
||||
express_map: Dict[str, str] = {}
|
||||
if express_ids:
|
||||
for e in (
|
||||
db.query(BizExpressCompany)
|
||||
.filter(BizExpressCompany.id.in_(express_ids), BizExpressCompany.del_flag == 0)
|
||||
.all()
|
||||
):
|
||||
express_map[e.id] = e.express_name or ""
|
||||
|
||||
reception_ids = (
|
||||
[reception_map[s.id].id for s, *_ in rows if s.id in reception_map]
|
||||
if reception_map
|
||||
else []
|
||||
)
|
||||
results_map = (
|
||||
check_result_svc.list_results_by_reception_ids(db, reception_ids, tenant_no)
|
||||
if reception_ids
|
||||
else {}
|
||||
)
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
for sample, protocol, party, transport in rows:
|
||||
item: Dict[str, Any] = {
|
||||
"protocolSampleId": sample.id,
|
||||
"collectionProtocolId": protocol.id,
|
||||
"protocolNo": protocol.protocol_no or "",
|
||||
"protocolStatus": protocol.protocol_status or "",
|
||||
"sampleCode": sample.sample_code or "",
|
||||
"sampleTypeId": sample.sample_type_id or "",
|
||||
"sampleTypeName": type_map.get(sample.sample_type_id or "", ""),
|
||||
"collectAmount": _dec(sample.collect_amount),
|
||||
"sampleStatus": sample.sample_status or "",
|
||||
"motherName": (party.mother_name if party else "") or "",
|
||||
"newbornName": (party.newborn_name if party else "") or "",
|
||||
"etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
|
||||
"transportType": (transport.transport_type if transport else "") or "",
|
||||
"waybillNo": (transport.waybill_no if transport else "") or "",
|
||||
"expressCompanyId": (transport.express_company_id if transport else "") or "",
|
||||
"expressCompanyName": express_map.get(
|
||||
(transport.express_company_id if transport else "") or "", ""
|
||||
),
|
||||
"carrierName": (transport.carrier_name if transport else "") or "",
|
||||
}
|
||||
if reception_map and sample.id in reception_map:
|
||||
rec = reception_map[sample.id]
|
||||
cr = results_map.get(rec.id)
|
||||
if not cr:
|
||||
cr = check_result_svc.get_check_results_for_reception(db, rec, tenant_no)
|
||||
item.update(_reception_to_dict(rec, check_results=cr))
|
||||
item["receiveAttachmentFiles"] = urls_to_attachment_list(
|
||||
rec.receive_attachments, db, tenant_no
|
||||
)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def list_pending_protocol_samples(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
tenant_no: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
if not tenant_no:
|
||||
return [], 0
|
||||
q_base = _sample_pending_query(db, tenant_no, keyword)
|
||||
total = q_base.order_by(None).with_entities(func.count(BizProtocolSample.id)).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = (
|
||||
q_base.order_by(
|
||||
BizProtocolSampleTransport.eta_time.is_(None),
|
||||
BizProtocolSampleTransport.eta_time.asc(),
|
||||
BizProtocolSample.create_time.asc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return _rows_to_sample_list_items(db, rows, tenant_no), int(total)
|
||||
|
||||
|
||||
def list_received_protocol_samples(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
tenant_no: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
if not tenant_no:
|
||||
return [], 0
|
||||
q_rec = db.query(BizSampleReception).filter(
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
)
|
||||
if keyword and keyword.strip():
|
||||
kw = f"%{keyword.strip()}%"
|
||||
sample_ids_kw = (
|
||||
_sample_detail_rows_query(db)
|
||||
.with_entities(BizProtocolSample.id)
|
||||
.filter(
|
||||
BizProtocolSample.del_flag == 0,
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
or_(
|
||||
BizCollectionProtocol.protocol_no.like(kw),
|
||||
BizProtocolSample.sample_code.like(kw),
|
||||
BizProtocolPartyCustomer.mother_name.like(kw),
|
||||
BizProtocolPartyCustomer.newborn_name.like(kw),
|
||||
),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
q_rec = q_rec.filter(
|
||||
or_(
|
||||
BizSampleReception.handler_name.like(kw),
|
||||
BizSampleReception.protocol_sample_id.in_(sample_ids_kw),
|
||||
)
|
||||
)
|
||||
|
||||
total = q_rec.count()
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
receptions = (
|
||||
q_rec.order_by(BizSampleReception.reception_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
if not receptions:
|
||||
return [], int(total)
|
||||
|
||||
sample_ids = [r.protocol_sample_id for r in receptions]
|
||||
reception_map = {r.protocol_sample_id: r for r in receptions}
|
||||
|
||||
rows = (
|
||||
_sample_detail_rows_query(db)
|
||||
.filter(BizProtocolSample.id.in_(sample_ids))
|
||||
.all()
|
||||
)
|
||||
row_by_sample = {row[0].id: row for row in rows}
|
||||
ordered_rows = [row_by_sample[sid] for sid in sample_ids if sid in row_by_sample]
|
||||
items = _rows_to_sample_list_items(
|
||||
db, ordered_rows, tenant_no, reception_map=reception_map
|
||||
)
|
||||
return items, int(total)
|
||||
|
||||
|
||||
def get_reception_by_id(
|
||||
db: Session, reception_id: str, tenant_no: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
rec = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(
|
||||
BizSampleReception.id == reception_id,
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not rec:
|
||||
return None
|
||||
sample = (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.id == rec.protocol_sample_id,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not sample:
|
||||
cr = check_result_svc.get_check_results_for_reception(db, rec, tenant_no)
|
||||
out = _reception_to_dict(rec, check_results=cr)
|
||||
out["receiveAttachmentFiles"] = urls_to_attachment_list(
|
||||
rec.receive_attachments, db, tenant_no
|
||||
)
|
||||
return out
|
||||
rows = (
|
||||
_sample_detail_rows_query(db)
|
||||
.filter(BizProtocolSample.id == sample.id)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
cr = check_result_svc.get_check_results_for_reception(db, rec, tenant_no)
|
||||
out = _reception_to_dict(rec, check_results=cr)
|
||||
out["receiveAttachmentFiles"] = urls_to_attachment_list(
|
||||
rec.receive_attachments, db, tenant_no
|
||||
)
|
||||
return out
|
||||
items = _rows_to_sample_list_items(
|
||||
db, rows, tenant_no, reception_map={rec.protocol_sample_id: rec}
|
||||
)
|
||||
return items[0] if items else _reception_to_dict(rec)
|
||||
|
||||
|
||||
def create_sample_reception(
|
||||
db: Session,
|
||||
*,
|
||||
protocol_sample_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
create_dept_id: str = "",
|
||||
reception_time: datetime,
|
||||
handler_user_id: Optional[str],
|
||||
handler_name: Optional[str],
|
||||
receive_temperature: Optional[str],
|
||||
volume_ml: Optional[float] = None,
|
||||
net_weight_g: Optional[float] = None,
|
||||
checks: List[Dict[str, Any]],
|
||||
accept_conclusion: str,
|
||||
receive_attachments: Optional[str],
|
||||
remark: Optional[str],
|
||||
) -> BizSampleReception:
|
||||
sample = (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.id == protocol_sample_id,
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not sample:
|
||||
raise ValueError("样本不存在")
|
||||
exists = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(
|
||||
BizSampleReception.protocol_sample_id == protocol_sample_id,
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
raise ValueError("该样本已登记接收,请勿重复操作")
|
||||
|
||||
conclusion = (accept_conclusion or "").strip()
|
||||
if conclusion not in ("js", "bj"):
|
||||
raise ValueError("接收结论无效")
|
||||
|
||||
handler_display = (handler_name or "").strip()
|
||||
if handler_user_id and not handler_display:
|
||||
u = db.query(SysUser).filter(SysUser.id == handler_user_id).first()
|
||||
if u:
|
||||
handler_display = u.nick_name or ""
|
||||
|
||||
obj = BizSampleReception(
|
||||
id=generate_uuid(),
|
||||
protocol_sample_id=protocol_sample_id,
|
||||
collection_protocol_id=sample.collection_protocol_id,
|
||||
reception_time=reception_time,
|
||||
handler_user_id=handler_user_id,
|
||||
handler_name=handler_display or None,
|
||||
receive_temperature=(receive_temperature or "").strip() or None,
|
||||
volume_ml=volume_ml,
|
||||
net_weight_g=net_weight_g,
|
||||
check_packaging=0,
|
||||
check_sealing=0,
|
||||
check_no_damage=0,
|
||||
check_no_leakage=0,
|
||||
check_color=0,
|
||||
check_tying_rope=0,
|
||||
check_no_stratification=0,
|
||||
accept_conclusion=conclusion,
|
||||
receive_attachments=(receive_attachments or "").strip() or None,
|
||||
tenant_no=tenant_no,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
update_by=user_id,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
check_result_svc.save_reception_check_results(
|
||||
db,
|
||||
reception_id=obj.id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
checks=checks,
|
||||
)
|
||||
from app.models.models import BizSampleReceptionCheckResult
|
||||
|
||||
saved_rows = (
|
||||
db.query(BizSampleReceptionCheckResult)
|
||||
.filter(
|
||||
BizSampleReceptionCheckResult.reception_id == obj.id,
|
||||
BizSampleReceptionCheckResult.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
check_result_svc.sync_legacy_columns_from_results(db, obj, saved_rows)
|
||||
db.add(obj)
|
||||
if conclusion == "js":
|
||||
from app.crud import qc_work_order as qc_wo_svc
|
||||
|
||||
qc_wo_svc.create_work_order_from_reception(
|
||||
db,
|
||||
reception=obj,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def get_receptions_map_for_samples(
|
||||
db: Session,
|
||||
sample_ids: List[str],
|
||||
tenant_no: str,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""按协议样本 ID 批量查询接收记录(含检查项明细、附件列表),供协议单查看弹窗使用。"""
|
||||
if not sample_ids or not tenant_no:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(
|
||||
BizSampleReception.protocol_sample_id.in_(sample_ids),
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
return {}
|
||||
reception_ids = [r.id for r in rows]
|
||||
results_map = check_result_svc.list_results_by_reception_ids(db, reception_ids, tenant_no)
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for rec in rows:
|
||||
cr = results_map.get(rec.id)
|
||||
if not cr:
|
||||
cr = check_result_svc.get_check_results_for_reception(db, rec, tenant_no)
|
||||
item = _reception_to_dict(rec, check_results=cr)
|
||||
item["receiveAttachmentFiles"] = urls_to_attachment_list(
|
||||
rec.receive_attachments, db, tenant_no
|
||||
)
|
||||
out[rec.protocol_sample_id] = item
|
||||
return out
|
||||
@@ -0,0 +1,213 @@
|
||||
"""样本接收检查项配置 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
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 BizSampleReceptionCheckItem
|
||||
|
||||
|
||||
def _row_to_dict(r: BizSampleReceptionCheckItem) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"itemCode": r.item_code,
|
||||
"itemName": r.item_name,
|
||||
"status": r.status or "1",
|
||||
"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 item_code_exists(
|
||||
db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
|
||||
) -> bool:
|
||||
c = (code or "").strip()
|
||||
if not c:
|
||||
return False
|
||||
q = db.query(BizSampleReceptionCheckItem).filter(
|
||||
and_(
|
||||
BizSampleReceptionCheckItem.item_code == c,
|
||||
BizSampleReceptionCheckItem.tenant_no == tenant_no,
|
||||
BizSampleReceptionCheckItem.del_flag == 0,
|
||||
)
|
||||
)
|
||||
if exclude_id:
|
||||
q = q.filter(BizSampleReceptionCheckItem.id != exclude_id)
|
||||
return q.first() is not None
|
||||
|
||||
|
||||
def _order_query(q):
|
||||
return q.order_by(
|
||||
BizSampleReceptionCheckItem.order_num.asc(),
|
||||
BizSampleReceptionCheckItem.create_time.desc(),
|
||||
)
|
||||
|
||||
|
||||
def list_check_items(
|
||||
db: Session,
|
||||
*,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
keyword: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
def _filtered():
|
||||
q0 = db.query(BizSampleReceptionCheckItem).filter(
|
||||
BizSampleReceptionCheckItem.del_flag == 0
|
||||
)
|
||||
if tenant_no:
|
||||
q0 = q0.filter(BizSampleReceptionCheckItem.tenant_no == tenant_no)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q0 = q0.filter(
|
||||
or_(
|
||||
BizSampleReceptionCheckItem.item_code.like(like),
|
||||
BizSampleReceptionCheckItem.item_name.like(like),
|
||||
)
|
||||
)
|
||||
st = (status or "").strip()
|
||||
if st in ("0", "1"):
|
||||
q0 = q0.filter(BizSampleReceptionCheckItem.status == st)
|
||||
return q0
|
||||
|
||||
total = _filtered().order_by(None).with_entities(
|
||||
func.count(BizSampleReceptionCheckItem.id)
|
||||
).scalar() or 0
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
rows = _order_query(_filtered()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return [_row_to_dict(x) for x in rows], int(total)
|
||||
|
||||
|
||||
def list_active_check_items(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
_order_query(
|
||||
db.query(BizSampleReceptionCheckItem).filter(
|
||||
BizSampleReceptionCheckItem.tenant_no == tenant_no,
|
||||
BizSampleReceptionCheckItem.del_flag == 0,
|
||||
BizSampleReceptionCheckItem.status == "1",
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(x) for x in rows]
|
||||
|
||||
|
||||
def get_check_item(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> Optional[BizSampleReceptionCheckItem]:
|
||||
q = db.query(BizSampleReceptionCheckItem).filter(
|
||||
and_(BizSampleReceptionCheckItem.id == row_id, BizSampleReceptionCheckItem.del_flag == 0)
|
||||
)
|
||||
if tenant_no:
|
||||
q = q.filter(BizSampleReceptionCheckItem.tenant_no == tenant_no)
|
||||
return q.first()
|
||||
|
||||
|
||||
def create_check_item(
|
||||
db: Session,
|
||||
*,
|
||||
item_code: str,
|
||||
item_name: str,
|
||||
status: str = "1",
|
||||
tenant_no: str,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizSampleReceptionCheckItem:
|
||||
code = (item_code or "").strip()
|
||||
if not code:
|
||||
raise ValueError("检查项编码不能为空")
|
||||
if item_code_exists(db, code, tenant_no):
|
||||
raise ValueError("检查项编码已存在")
|
||||
name = (item_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("检查项名称不能为空")
|
||||
st = (status or "1").strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj = BizSampleReceptionCheckItem(
|
||||
id=generate_uuid(),
|
||||
item_code=code,
|
||||
item_name=name,
|
||||
status=st,
|
||||
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_dept_id or "",
|
||||
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_check_item(
|
||||
db: Session,
|
||||
row_id: str,
|
||||
*,
|
||||
item_code: Optional[str] = None,
|
||||
item_name: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
order_num: Optional[int] = None,
|
||||
remark: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> BizSampleReceptionCheckItem:
|
||||
obj = get_check_item(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("检查项不存在")
|
||||
if item_code is not None:
|
||||
code = item_code.strip()
|
||||
if not code:
|
||||
raise ValueError("检查项编码不能为空")
|
||||
if code != obj.item_code and item_code_exists(db, code, obj.tenant_no, exclude_id=row_id):
|
||||
raise ValueError("检查项编码已存在")
|
||||
obj.item_code = code
|
||||
if item_name is not None:
|
||||
name = (item_name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("检查项名称不能为空")
|
||||
obj.item_name = name
|
||||
if status is not None:
|
||||
st = status.strip()
|
||||
if st not in ("0", "1"):
|
||||
raise ValueError("状态无效")
|
||||
obj.status = st
|
||||
if order_num is not None:
|
||||
obj.order_num = order_num
|
||||
if remark is not None:
|
||||
obj.remark = remark.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_check_item(
|
||||
db: Session, row_id: str, tenant_no: Optional[str] = None
|
||||
) -> None:
|
||||
obj = get_check_item(db, row_id, tenant_no=tenant_no)
|
||||
if not obj:
|
||||
raise ValueError("检查项不存在")
|
||||
obj.del_flag = 1
|
||||
obj.update_time = datetime.now()
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""样本接收检查项结果 CRUD"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud import sample_reception_check_item as check_item_svc
|
||||
from app.models.models import (
|
||||
BizSampleReception,
|
||||
BizSampleReceptionCheckItem,
|
||||
BizSampleReceptionCheckResult,
|
||||
)
|
||||
|
||||
_LEGACY_ITEMS = (
|
||||
("packaging", "包装", "check_packaging"),
|
||||
("sealing", "封口", "check_sealing"),
|
||||
("no_damage", "无破损", "check_no_damage"),
|
||||
("no_leakage", "无漏液", "check_no_leakage"),
|
||||
("color", "颜色", "check_color"),
|
||||
("tying_rope", "系绳", "check_tying_rope"),
|
||||
("no_stratification", "无分层", "check_no_stratification"),
|
||||
)
|
||||
|
||||
|
||||
def _result_to_dict(r: BizSampleReceptionCheckResult) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"checkItemId": r.check_item_id,
|
||||
"itemCode": r.item_code,
|
||||
"itemName": r.item_name,
|
||||
"checked": bool(r.checked),
|
||||
"orderNum": r.order_num if r.order_num is not None else 0,
|
||||
}
|
||||
|
||||
|
||||
def list_results_by_reception_ids(
|
||||
db: Session, reception_ids: List[str], tenant_no: str
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
if not reception_ids:
|
||||
return {}
|
||||
rows = (
|
||||
db.query(BizSampleReceptionCheckResult)
|
||||
.filter(
|
||||
BizSampleReceptionCheckResult.reception_id.in_(reception_ids),
|
||||
BizSampleReceptionCheckResult.tenant_no == tenant_no,
|
||||
BizSampleReceptionCheckResult.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizSampleReceptionCheckResult.order_num.asc(),
|
||||
BizSampleReceptionCheckResult.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
out: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for r in rows:
|
||||
out.setdefault(r.reception_id, []).append(_result_to_dict(r))
|
||||
return out
|
||||
|
||||
|
||||
def _legacy_results_from_reception(rec: BizSampleReception) -> List[Dict[str, Any]]:
|
||||
items: List[Dict[str, Any]] = []
|
||||
for order, (code, name, attr) in enumerate(_LEGACY_ITEMS, start=1):
|
||||
items.append(
|
||||
{
|
||||
"id": "",
|
||||
"checkItemId": "",
|
||||
"itemCode": code,
|
||||
"itemName": name,
|
||||
"checked": bool(getattr(rec, attr, 0)),
|
||||
"orderNum": order,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def get_check_results_for_reception(
|
||||
db: Session, rec: BizSampleReception, tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizSampleReceptionCheckResult)
|
||||
.filter(
|
||||
BizSampleReceptionCheckResult.reception_id == rec.id,
|
||||
BizSampleReceptionCheckResult.tenant_no == tenant_no,
|
||||
BizSampleReceptionCheckResult.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizSampleReceptionCheckResult.order_num.asc(),
|
||||
BizSampleReceptionCheckResult.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if rows:
|
||||
return [_result_to_dict(r) for r in rows]
|
||||
return _legacy_results_from_reception(rec)
|
||||
|
||||
|
||||
def save_reception_check_results(
|
||||
db: Session,
|
||||
*,
|
||||
reception_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
create_dept_id: str,
|
||||
checks: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""按当前租户启用检查项写入结果;checks: [{checkItemId, checked}]"""
|
||||
active_items = check_item_svc.list_active_check_items(db, tenant_no)
|
||||
if not active_items:
|
||||
raise ValueError("未配置启用的检查项,请先在「接收检查项配置」中维护")
|
||||
submitted = {
|
||||
str(c.get("checkItemId") or c.get("check_item_id") or ""): bool(c.get("checked"))
|
||||
for c in (checks or [])
|
||||
}
|
||||
active_ids = {str(x["id"]) for x in active_items}
|
||||
unknown = [k for k in submitted if k and k not in active_ids]
|
||||
if unknown:
|
||||
raise ValueError("存在无效的检查项")
|
||||
now = datetime.now()
|
||||
for item in active_items:
|
||||
item_id = str(item["id"])
|
||||
checked = submitted.get(item_id, False)
|
||||
row = BizSampleReceptionCheckResult(
|
||||
id=generate_uuid(),
|
||||
reception_id=reception_id,
|
||||
check_item_id=item_id,
|
||||
item_code=item["itemCode"],
|
||||
item_name=item["itemName"],
|
||||
checked=1 if checked else 0,
|
||||
tenant_no=tenant_no,
|
||||
order_num=item.get("orderNum") or 0,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
|
||||
|
||||
def sync_legacy_columns_from_results(
|
||||
db: Session, rec: BizSampleReception, results: List[BizSampleReceptionCheckResult]
|
||||
) -> None:
|
||||
"""兼容旧列:按 item_code 回写 biz_sample_reception.check_*"""
|
||||
code_map = {r.item_code: bool(r.checked) for r in results}
|
||||
rec.check_packaging = 1 if code_map.get("packaging") else 0
|
||||
rec.check_sealing = 1 if code_map.get("sealing") else 0
|
||||
rec.check_no_damage = 1 if code_map.get("no_damage") else 0
|
||||
rec.check_no_leakage = 1 if code_map.get("no_leakage") else 0
|
||||
rec.check_color = 1 if code_map.get("color") else 0
|
||||
rec.check_tying_rope = 1 if code_map.get("tying_rope") else 0
|
||||
rec.check_no_stratification = 1 if code_map.get("no_stratification") else 0
|
||||
+3
-1
@@ -10,7 +10,7 @@ from app.core.database import SessionLocal
|
||||
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
|
||||
from app.core.api_crypto_middleware import ApiCryptoMiddleware
|
||||
from app.models import models # noqa: F401
|
||||
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router
|
||||
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
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -67,6 +67,8 @@ app.include_router(collection_protocol_router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(leave_router, prefix=settings.API_V1_PREFIX)
|
||||
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)
|
||||
|
||||
logger = logging.getLogger("sqxp")
|
||||
|
||||
|
||||
@@ -982,6 +982,261 @@ class BizProtocolStorageCert(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizSampleReceptionCheckItem(Base):
|
||||
"""样本接收检查项配置 biz_sample_reception_check_item"""
|
||||
|
||||
__tablename__ = "biz_sample_reception_check_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
item_code = Column(String(64), nullable=False, comment="检查项编码")
|
||||
item_name = Column(String(200), nullable=False, comment="检查项名称")
|
||||
status = Column(String(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 BizSampleReceptionCheckResult(Base):
|
||||
"""样本接收检查项结果 biz_sample_reception_check_result"""
|
||||
|
||||
__tablename__ = "biz_sample_reception_check_result"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
reception_id = Column(String(64), nullable=False, comment="接收记录ID")
|
||||
check_item_id = Column(String(64), nullable=False, comment="检查项配置ID")
|
||||
item_code = Column(String(64), nullable=False, comment="检查项编码快照")
|
||||
item_name = Column(String(200), nullable=False, comment="检查项名称快照")
|
||||
checked = Column(SmallInteger, nullable=False, default=0, 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 BizSampleReception(Base):
|
||||
"""样本接收 biz_sample_reception"""
|
||||
|
||||
__tablename__ = "biz_sample_reception"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
protocol_sample_id = Column(String(64), nullable=False, comment="协议样本ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="采集协议单ID")
|
||||
reception_time = Column(DateTime, nullable=False, comment="样本接收时间")
|
||||
handler_user_id = Column(String(64), nullable=True, comment="经手人用户ID")
|
||||
handler_name = Column(String(100), nullable=True, comment="经手人姓名")
|
||||
receive_temperature = Column(String(32), nullable=True, comment="接收温度")
|
||||
volume_ml = Column(Numeric(18, 4), nullable=True, comment="体积(ml)")
|
||||
net_weight_g = Column(Numeric(18, 4), nullable=True, comment="净重(g)")
|
||||
check_packaging = Column(SmallInteger, nullable=False, default=0, comment="包装检查")
|
||||
check_sealing = Column(SmallInteger, nullable=False, default=0, comment="封口检查")
|
||||
check_no_damage = Column(SmallInteger, nullable=False, default=0, comment="无破损")
|
||||
check_no_leakage = Column(SmallInteger, nullable=False, default=0, comment="无漏液")
|
||||
check_color = Column(SmallInteger, nullable=False, default=0, comment="颜色")
|
||||
check_tying_rope = Column(SmallInteger, nullable=False, default=0, comment="系绳")
|
||||
check_no_stratification = Column(SmallInteger, nullable=False, default=0, comment="无分层")
|
||||
accept_conclusion = Column(String(16), nullable=False, comment="结论 js接收 bj拒收")
|
||||
receive_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 BizQcThirdPartyCompany(Base):
|
||||
"""第三方检测公司 biz_qc_third_party_company"""
|
||||
|
||||
__tablename__ = "biz_qc_third_party_company"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
company_code = Column(String(64), nullable=False, comment="公司编码")
|
||||
company_name = Column(String(200), nullable=False, comment="公司名称")
|
||||
contact_name = Column(String(100), nullable=True, comment="联系人")
|
||||
contact_phone = Column(String(32), nullable=True, comment="联系电话")
|
||||
address = Column(String(500), nullable=True, comment="地址")
|
||||
invoice_title = Column(String(200), nullable=True, comment="发票抬头")
|
||||
tax_id = Column(String(64), nullable=True, comment="纳税人识别号")
|
||||
bank_name = Column(String(200), nullable=True, comment="开户银行")
|
||||
bank_account = Column(String(64), nullable=True, comment="银行账号")
|
||||
status = Column(String(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 BizQcThirdPartyItem(Base):
|
||||
"""第三方检测项目 biz_qc_third_party_item"""
|
||||
|
||||
__tablename__ = "biz_qc_third_party_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
company_id = Column(String(64), nullable=False, comment="第三方公司ID")
|
||||
item_code = Column(String(64), nullable=False, comment="项目编码")
|
||||
item_name = Column(String(200), nullable=False, comment="项目名称")
|
||||
fee_amount = Column(Numeric(18, 2), nullable=False, default=0, comment="费用元")
|
||||
status = Column(String(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 BizQcLabTestItem(Base):
|
||||
"""实验室自检项目 biz_qc_lab_test_item"""
|
||||
|
||||
__tablename__ = "biz_qc_lab_test_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
item_code = Column(String(64), nullable=False, comment="项目编码")
|
||||
item_name = Column(String(200), nullable=False, comment="项目名称")
|
||||
description = Column(String(500), nullable=True, comment="描述")
|
||||
status = Column(String(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 BizQcWorkOrder(Base):
|
||||
"""质检工单 biz_qc_work_order"""
|
||||
|
||||
__tablename__ = "biz_qc_work_order"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
work_order_no = Column(String(32), nullable=False, comment="工单号")
|
||||
reception_id = Column(String(64), nullable=False, comment="接收记录ID")
|
||||
protocol_sample_id = Column(String(64), nullable=False, comment="协议样本ID")
|
||||
collection_protocol_id = Column(String(64), nullable=False, comment="协议单ID")
|
||||
protocol_no = Column(String(64), nullable=True, comment="协议号快照")
|
||||
sample_code = Column(String(32), nullable=True, comment="样本编码快照")
|
||||
work_order_status = Column(String(16), nullable=False, default="dpz", comment="工单状态")
|
||||
has_lab = Column(String(1), nullable=False, default="0", comment="含实验室自检")
|
||||
has_third_party = Column(String(1), nullable=False, default="0", comment="含第三方检测")
|
||||
configured_time = Column(DateTime, nullable=True, comment="配置时间")
|
||||
configured_by = Column(String(64), nullable=True, comment="配置人")
|
||||
third_party_fee_total = 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 BizQcWorkOrderLabItem(Base):
|
||||
"""质检工单-实验室项目明细 biz_qc_work_order_lab_item"""
|
||||
|
||||
__tablename__ = "biz_qc_work_order_lab_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
work_order_id = Column(String(64), nullable=False, comment="工单ID")
|
||||
lab_test_item_id = Column(String(64), nullable=False, comment="实验室项目ID")
|
||||
item_code = Column(String(64), nullable=False, comment="项目编码快照")
|
||||
item_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 BizQcWorkOrderThirdItem(Base):
|
||||
"""质检工单-第三方项目明细 biz_qc_work_order_third_item"""
|
||||
|
||||
__tablename__ = "biz_qc_work_order_third_item"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
work_order_id = Column(String(64), nullable=False, comment="工单ID")
|
||||
third_party_item_id = Column(String(64), nullable=False, comment="第三方项目ID")
|
||||
company_id = Column(String(64), nullable=False, comment="公司ID")
|
||||
company_name = Column(String(200), nullable=False, comment="公司名快照")
|
||||
item_code = Column(String(64), nullable=False, comment="项目编码快照")
|
||||
item_name = Column(String(200), nullable=False, comment="项目名称快照")
|
||||
fee_amount = Column(Numeric(18, 2), nullable=False, default=0, 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 BizQcTask(Base):
|
||||
"""质检任务 biz_qc_task"""
|
||||
|
||||
__tablename__ = "biz_qc_task"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
work_order_id = Column(String(64), nullable=False, comment="工单ID")
|
||||
task_no = Column(String(32), nullable=False, comment="任务号")
|
||||
task_type = Column(String(16), nullable=False, comment="sysz实验室 dsfjc第三方")
|
||||
source_line_id = Column(String(64), nullable=False, comment="工单明细行ID")
|
||||
item_code = Column(String(64), nullable=False, comment="项目编码快照")
|
||||
item_name = Column(String(200), nullable=False, comment="项目名称快照")
|
||||
company_id = Column(String(64), nullable=True, comment="第三方公司ID")
|
||||
company_name = Column(String(200), nullable=True, comment="第三方公司名")
|
||||
fee_amount = Column(Numeric(18, 2), nullable=True, comment="费用快照")
|
||||
task_status = Column(String(16), nullable=False, default="dzx", comment="任务状态")
|
||||
third_party_attachments = Column(String(4000), nullable=True, comment="第三方材料")
|
||||
completed_time = Column(DateTime, nullable=True, comment="完成时间")
|
||||
completed_by = 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="删除标记")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# V0.4 运营支撑(2.4 洁净室管理 / 库位管理 / 展馆预约)
|
||||
# =============================================================================
|
||||
@@ -1096,3 +1351,6 @@ class BizExhibitionAppointment(Base):
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
from app.models.qc_param import BizQcItemParam, BizQcTaskResult # noqa: E402,F401
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CHAR, Column, DateTime, Integer, SmallInteger, String
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BizQcItemParam(Base):
|
||||
__tablename__ = "biz_qc_item_param"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
item_source = Column(String(16), nullable=False, comment="lab实验室 third_party第三方")
|
||||
item_id = Column(String(64), nullable=False, comment="检测项目ID")
|
||||
param_name = Column(String(200), nullable=False, comment="参数名称")
|
||||
param_code = Column(String(64), nullable=False, comment="参数编码")
|
||||
param_unit = Column(String(32), nullable=True, comment="单位")
|
||||
param_type = Column(String(16), nullable=False, default="text", comment="number/text/select")
|
||||
required_flag = Column(CHAR(1), nullable=False, default="0", 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 BizQcTaskResult(Base):
|
||||
__tablename__ = "biz_qc_task_result"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
task_id = Column(String(64), nullable=False, comment="质检任务ID")
|
||||
param_id = Column(String(64), nullable=True, comment="参数模板ID")
|
||||
param_name = Column(String(200), nullable=False, comment="参数名快照")
|
||||
param_code = Column(String(64), nullable=True, comment="参数编码快照")
|
||||
param_unit = Column(String(32), nullable=True, comment="单位快照")
|
||||
param_type = Column(String(16), nullable=False, default="text", comment="类型快照")
|
||||
param_value = Column(String(500), nullable=True, comment="填写值")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 检测项目参数模板
|
||||
CREATE TABLE IF NOT EXISTS `biz_qc_item_param` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`item_source` varchar(16) NOT NULL COMMENT 'lab实验室 third_party第三方',
|
||||
`item_id` varchar(64) NOT NULL COMMENT '检测项目ID',
|
||||
`param_name` varchar(200) NOT NULL COMMENT '参数名称',
|
||||
`param_code` varchar(64) NOT NULL COMMENT '参数编码',
|
||||
`param_unit` varchar(32) DEFAULT NULL COMMENT '单位',
|
||||
`param_type` varchar(16) NOT NULL DEFAULT 'text' COMMENT 'number/text/select',
|
||||
`required_flag` char(1) NOT NULL DEFAULT '0' 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 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_qc_param_item` (`item_source`, `item_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='检测项目参数模板';
|
||||
|
||||
-- 质检任务检测结果
|
||||
CREATE TABLE IF NOT EXISTS `biz_qc_task_result` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`task_id` varchar(64) NOT NULL COMMENT '质检任务ID',
|
||||
`param_id` varchar(64) DEFAULT NULL COMMENT '参数模板ID',
|
||||
`param_name` varchar(200) NOT NULL COMMENT '参数名快照',
|
||||
`param_code` varchar(64) DEFAULT NULL COMMENT '参数编码快照',
|
||||
`param_unit` varchar(32) DEFAULT NULL COMMENT '单位快照',
|
||||
`param_type` varchar(16) NOT NULL DEFAULT 'text' COMMENT '类型快照',
|
||||
`param_value` varchar(500) DEFAULT NULL COMMENT '填写值',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' 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_qc_result_task` (`task_id`, `del_flag`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='质检任务检测结果';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 第三方检测公司:新增开票信息字段
|
||||
ALTER TABLE `biz_qc_third_party_company`
|
||||
ADD COLUMN `invoice_title` varchar(200) DEFAULT NULL COMMENT '发票抬头' AFTER `address`,
|
||||
ADD COLUMN `tax_id` varchar(64) DEFAULT NULL COMMENT '纳税人识别号' AFTER `invoice_title`,
|
||||
ADD COLUMN `bank_name` varchar(200) DEFAULT NULL COMMENT '开户银行' AFTER `tax_id`,
|
||||
ADD COLUMN `bank_account` varchar(64) DEFAULT NULL COMMENT '银行账号' AFTER `bank_name`;
|
||||
@@ -0,0 +1,160 @@
|
||||
-- ============================================================
|
||||
-- 质检模块基础测试数据
|
||||
-- 涵盖:第三方检测公司 → 第三方检测项目 → 实验室自检项目
|
||||
-- 租户编号:zkxw
|
||||
-- ============================================================
|
||||
|
||||
-- ----------------------------
|
||||
-- 1. 第三方检测公司(6家)
|
||||
-- ----------------------------
|
||||
INSERT INTO `biz_qc_third_party_company`
|
||||
(`id`, `company_code`, `company_name`, `contact_name`, `contact_phone`, `address`,
|
||||
`invoice_title`, `tax_id`, `bank_name`, `bank_account`,
|
||||
`status`, `tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES
|
||||
('qc_tpc_001', 'DSF001', '安徽中检技术服务有限公司', '张建国', '13912345601',
|
||||
'合肥市高新区望江西路800号A栋3楼', '安徽中检技术服务有限公司', '91340100MA2N0XYZ01',
|
||||
'中国建设银行合肥高新区支行', '34001688608053001234',
|
||||
'1', 'zkxw', 1, '长期合作方,具备CMA/CNAS资质',
|
||||
NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_tpc_002', 'DSF002', '上海谱尼测试集团合肥分公司', '李明华', '13912345602',
|
||||
'合肥市包河区繁华大道6588号', '上海谱尼测试集团有限公司', '9131011578056XYZ02',
|
||||
'中国工商银行上海浦东支行', '10012029192001234567',
|
||||
'1', 'zkxw', 2, '可做微生物、重金属检测',
|
||||
NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_tpc_003', 'DSF003', '华测检测认证集团股份有限公司', '王晓燕', '13912345603',
|
||||
'合肥市蜀山区长江西路1899号', '华测检测认证集团股份有限公司', '9144030077163XYZ03',
|
||||
'招商银行深圳福田支行', '755916238910001',
|
||||
'1', 'zkxw', 3, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_tpc_004', 'DSF004', '中检集团安徽有限公司', '赵伟', '13912345604',
|
||||
'合肥市经开区翠微路12号', '中检集团安徽有限公司', '9134010068888XYZ04',
|
||||
'中国银行合肥政务区支行', '18723456789001',
|
||||
'1', 'zkxw', 4, '综合性检测机构',
|
||||
NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_tpc_005', 'DSF005', '安徽国科检测科技有限公司', '刘芳', '13912345605',
|
||||
'合肥市庐阳区濉溪路310号', '安徽国科检测科技有限公司', '91340100MA2UWEXYZ5',
|
||||
'中国农业银行合肥庐阳支行', '12260101040012345',
|
||||
'1', 'zkxw', 5, NULL,
|
||||
NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_tpc_006', 'DSF006', '合肥微谱分析检测有限公司', '陈志强', '13912345606',
|
||||
'合肥市高新区科学大道118号', '合肥微谱分析检测有限公司', '91340100MA2N0KXYZ6',
|
||||
'交通银行合肥高新区支行', '341201000018012345678',
|
||||
'0', 'zkxw', 6, '已暂停合作,状态停用',
|
||||
NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- 2. 第三方检测项目(每家公司2~3个项目,共14个)
|
||||
-- ----------------------------
|
||||
INSERT INTO `biz_qc_third_party_item`
|
||||
(`id`, `company_id`, `item_code`, `item_name`, `fee_amount`, `status`,
|
||||
`tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES
|
||||
-- ── 安徽中检技术服务有限公司 (DSF001) ──
|
||||
('qc_tpi_001', 'qc_tpc_001', 'DSF001-001', '细胞活性检测', 800.00, '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_002', 'qc_tpc_001', 'DSF001-002', '微生物限度检查', 1200.00, '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_003', 'qc_tpc_001', 'DSF001-003', '内毒素检测(鲎试剂法)', 1500.00, '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── 上海谱尼测试集团合肥分公司 (DSF002) ──
|
||||
('qc_tpi_004', 'qc_tpc_002', 'DSF002-001', '重金属含量检测(铅镉汞砷)', 2000.00, '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_005', 'qc_tpc_002', 'DSF002-002', '细胞表型流式分析', 2500.00, '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── 华测检测认证集团股份有限公司 (DSF003) ──
|
||||
('qc_tpi_006', 'qc_tpc_003', 'DSF003-001', '支原体检测(PCR法)', 600.00, '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_007', 'qc_tpc_003', 'DSF003-002', '无菌检测', 900.00, '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_008', 'qc_tpc_003', 'DSF003-003', '细胞鉴别(STR图谱)', 1800.00, '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── 中检集团安徽有限公司 (DSF004) ──
|
||||
('qc_tpi_009', 'qc_tpc_004', 'DSF004-001', '乙肝五项检测', 500.00, '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_010', 'qc_tpc_004', 'DSF004-002', 'HIV/HCV/梅毒联合筛查', 800.00, '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── 安徽国科检测科技有限公司 (DSF005) ──
|
||||
('qc_tpi_011', 'qc_tpc_005', 'DSF005-001', '细胞计数与存活率', 300.00, '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_012', 'qc_tpc_005', 'DSF005-002', '病毒检测(qPCR法)', 1600.00, '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_tpi_013', 'qc_tpc_005', 'DSF005-003', '染色体核型分析', 2200.00, '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── 合肥微谱分析检测有限公司 (DSF006, 停用) ──
|
||||
('qc_tpi_014', 'qc_tpc_006', 'DSF006-001', '蛋白质含量测定', 700.00, '0', 'zkxw', 1, '公司已暂停合作', NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- 3. 实验室自检项目(8个)
|
||||
-- ----------------------------
|
||||
INSERT INTO `biz_qc_lab_test_item`
|
||||
(`id`, `item_code`, `item_name`, `description`, `status`,
|
||||
`tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES
|
||||
('qc_lti_001', 'LAB001', '细胞计数与活力检测',
|
||||
'使用台盼蓝排斥法或自动细胞计数仪,检测样本中细胞总数及存活率',
|
||||
'1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_lti_002', 'LAB002', '支原体检测(培养法)',
|
||||
'采用支原体培养基法,观察14天,检测细胞培养物中是否污染支原体',
|
||||
'1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_lti_003', 'LAB003', '无菌检测(直接接种法)',
|
||||
'将样本分别接种于需氧/厌氧培养基,观察14天,判断是否无菌',
|
||||
'1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_lti_004', 'LAB004', '内毒素检测(凝胶法)',
|
||||
'使用鲎试剂凝胶法测定样本中的细菌内毒素含量',
|
||||
'1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_lti_005', 'LAB005', '细胞表型检测(CD标记)',
|
||||
'采用流式细胞术检测细胞表面标记物,如CD34、CD45、CD73、CD90等',
|
||||
'1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_lti_006', 'LAB006', '细胞分化能力检测',
|
||||
'诱导分化后通过染色或流式分析,验证干细胞的成骨/成脂/成软骨分化能力',
|
||||
'1', 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_lti_007', 'LAB007', '病原微生物快速筛查',
|
||||
'采用PCR/RT-PCR方法快速筛查乙肝、丙肝、HIV、梅毒、巨细胞病毒等',
|
||||
'1', 'zkxw', 7, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
('qc_lti_008', 'LAB008', '渗透压检测',
|
||||
'使用渗透压仪测定细胞冻存液或培养基的渗透压值',
|
||||
'0', 'zkxw', 8, '暂未开展,保留项目', NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
|
||||
-- ----------------------------
|
||||
-- 4. 检测项目参数模板(为部分项目配置参数)
|
||||
-- ----------------------------
|
||||
INSERT INTO `biz_qc_item_param`
|
||||
(`id`, `item_source`, `item_id`, `param_name`, `param_code`, `param_unit`, `param_type`, `required_flag`,
|
||||
`tenant_no`, `order_num`, `remark`,
|
||||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES
|
||||
-- ── LAB001 细胞计数与活力检测 的参数 ──
|
||||
('qc_ip_001', 'lab', 'qc_lti_001', '细胞总数', 'total_count', '×10⁶', 'number', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_002', 'lab', 'qc_lti_001', '存活率', 'viability', '%', 'number', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_003', 'lab', 'qc_lti_001', '检测方法', 'method', NULL, 'text', '0', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── LAB004 内毒素检测(凝胶法) 的参数 ──
|
||||
('qc_ip_004', 'lab', 'qc_lti_004', '内毒素含量', 'endotoxin', 'EU/mL', 'number', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_005', 'lab', 'qc_lti_004', '限值标准', 'limit_std', 'EU/mL', 'number', '0', 'zkxw', 2, '根据剂型不同限值不同', NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_006', 'lab', 'qc_lti_004', '判定结果', 'result', NULL, 'text', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── LAB005 细胞表型检测 的参数 ──
|
||||
('qc_ip_007', 'lab', 'qc_lti_005', 'CD34阳性率', 'cd34_pos', '%', 'number', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_008', 'lab', 'qc_lti_005', 'CD45阳性率', 'cd45_pos', '%', 'number', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_009', 'lab', 'qc_lti_005', 'CD73阳性率', 'cd73_pos', '%', 'number', '0', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── DSF001-001 细胞活性检测(第三方)的参数 ──
|
||||
('qc_ip_010', 'third_party', 'qc_tpi_001', '细胞活性率', 'activity_rate', '%', 'number', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_011', 'third_party', 'qc_tpi_001', '检测依据', 'standard', NULL, 'text', '0', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
|
||||
-- ── DSF002-001 重金属含量检测(第三方)的参数 ──
|
||||
('qc_ip_012', 'third_party', 'qc_tpi_004', '铅(Pb)', 'pb', 'mg/kg', 'number', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_013', 'third_party', 'qc_tpi_004', '镉(Cd)', 'cd', 'mg/kg', 'number', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_014', 'third_party', 'qc_tpi_004', '汞(Hg)', 'hg', 'mg/kg', 'number', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
('qc_ip_015', 'third_party', 'qc_tpi_004', '砷(As)', 'as', 'mg/kg', 'number', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
+3786
-707
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,688 @@
|
||||
# 权限系统全面修复实施计划
|
||||
|
||||
> **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:** 补全 SQL 权限模型(sys_menu, sys_role, sys_role_menu, sys_role_data_scope_detail, sys_user_role)定义的全部权限执行逻辑,使菜单权限和数据权限在后端 API 和前端 UI 层真正生效。
|
||||
|
||||
**Architecture:** 后端添加 `data_scope.py` 服务模块实现数据权限过滤,在现有路由上叠加 `require_menu_code()` 菜单权限守卫;前端用户管理添加角色选择器,业务页面按钮添加 `hasAuth` 权限控制。
|
||||
|
||||
**Tech Stack:** Python/FastAPI/SQLAlchemy (backend), Vue 3/TypeScript/Element Plus (frontend)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 后端新增/修改文件
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| 新增 | `backend/app/core/data_scope.py` | 数据权限过滤服务:读取用户角色 data_scope,构建 SQLAlchemy 过滤条件 |
|
||||
| 修改 | `backend/app/api/basic_info.py` | 客户/样本类型/采集地区/协议号/样本编码 路由添加 `require_menu_code` + `apply_data_scope` |
|
||||
| 修改 | `backend/app/api/operations.py` | 洁净室/仓储/展厅预约 路由添加 `require_menu_code` + `apply_data_scope` |
|
||||
| 修改 | `backend/app/api/collection_protocol.py` | 采集协议路由添加 `require_menu_code` + `apply_data_scope` |
|
||||
| 修改 | `backend/app/api/sample_reception.py` | 样本接收路由添加 `require_menu_code` + `apply_data_scope` |
|
||||
| 修改 | `backend/app/api/admin.py` | 用户/角色管理路由添加 `require_menu_code`(仅业务部分) |
|
||||
| 修改 | `backend/app/api/public.py` | 修复 admin 无权限码返回 `all:true` 的安全漏洞 |
|
||||
| 新增 | `docs/sql/sys_menu_button_perms.sql` | 为每个 C 类型菜单补充 F 类型按钮权限记录 |
|
||||
|
||||
### 前端修改文件
|
||||
|
||||
| 操作 | 文件 | 职责 |
|
||||
|------|------|------|
|
||||
| 修改 | `web-admin/src/views/system/user/index.vue` | 用户弹窗添加角色多选下拉框 |
|
||||
| 修改 | `web-admin/src/views/basic-info/customer/index.vue` | 按钮添加 hasAuth 权限控制 |
|
||||
| 修改 | `web-admin/src/views/basic-info/cell-sample-type/index.vue` | 按钮添加 hasAuth 权限控制 |
|
||||
| 修改 | `web-admin/src/views/basic-info/collection-region/index.vue` | 按钮添加 hasAuth 权限控制 |
|
||||
| 修改 | `web-admin/src/views/basic-info/collection-protocol-no/index.vue` | 按钮添加 hasAuth 权限控制 |
|
||||
| 修改 | `web-admin/src/views/basic-info/sample-code/index.vue` | 按钮添加 hasAuth 权限控制 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 F 类型按钮权限 SQL 脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/sys_menu_button_perms.sql`
|
||||
|
||||
**说明:** 当前 `sys_menu` 中 C 类型菜单的 `perms` 字段(如 `biz:customer:list`)用于列表页面权限。需为每个 C 类型菜单新增 F 类型子记录,用于增删改操作权限控制。
|
||||
|
||||
- [ ] **Step 1: 编写 SQL 脚本**
|
||||
|
||||
```sql
|
||||
-- sys_menu 按钮(F)权限初始化
|
||||
-- 为每个 C 类型菜单新增 list/add/edit/delete 四个 F 类型按钮权限
|
||||
-- 执行前请确认 sys_menu 中已有的 C 类型菜单记录
|
||||
|
||||
-- ============================================================
|
||||
-- 客户管理 (parent: 121, perms prefix: biz:customer)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '客户查询', '121', 'F', '', NULL, 'biz:customer:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '客户新增', '121', 'F', '', NULL, 'biz:customer:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '客户修改', '121', 'F', '', NULL, 'biz:customer:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '客户删除', '121', 'F', '', NULL, 'biz:customer:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 细胞样本类型管理 (parent: 122, perms prefix: biz:cellSampleType)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '样本类型查询', '122', 'F', '', NULL, 'biz:cellSampleType:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本类型新增', '122', 'F', '', NULL, 'biz:cellSampleType:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本类型修改', '122', 'F', '', NULL, 'biz:cellSampleType:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本类型删除', '122', 'F', '', NULL, 'biz:cellSampleType:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 采集地区及医院管理 (parent: 123, perms prefix: biz:collectionRegion)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '采集地区查询', '123', 'F', '', NULL, 'biz:collectionRegion:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '采集地区新增', '123', 'F', '', NULL, 'biz:collectionRegion:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '采集地区修改', '123', 'F', '', NULL, 'biz:collectionRegion:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '采集地区删除', '123', 'F', '', NULL, 'biz:collectionRegion:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 采集协议号管理 (parent: 124, perms prefix: biz:collectionProtocolNo)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '协议号查询', '124', 'F', '', NULL, 'biz:collectionProtocolNo:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '协议号新增', '124', 'F', '', NULL, 'biz:collectionProtocolNo:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '协议号修改', '124', 'F', '', NULL, 'biz:collectionProtocolNo:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '协议号删除', '124', 'F', '', NULL, 'biz:collectionProtocolNo:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 样本编码管理 (parent: 125, perms prefix: biz:sampleCode)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '样本编码查询', '125', 'F', '', NULL, 'biz:sampleCode:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本编码新增', '125', 'F', '', NULL, 'biz:sampleCode:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本编码修改', '125', 'F', '', NULL, 'biz:sampleCode:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本编码删除', '125', 'F', '', NULL, 'biz:sampleCode:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 租户管理 (parent: 101, perms prefix: system:tenant) — super_admin only
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '租户查询', '101', 'F', '', NULL, 'system:tenant:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '租户新增', '101', 'F', '', NULL, 'system:tenant:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '租户修改', '101', 'F', '', NULL, 'system:tenant:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '租户删除', '101', 'F', '', NULL, 'system:tenant:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 字典管理 (parent: 102, perms prefix: system:dict)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '字典查询', '102', 'F', '', NULL, 'system:dict:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '字典新增', '102', 'F', '', NULL, 'system:dict:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '字典修改', '102', 'F', '', NULL, 'system:dict:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '字典删除', '102', 'F', '', NULL, 'system:dict:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 组织管理 (parent: 103, perms prefix: system:dept)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '组织查询', '103', 'F', '', NULL, 'system:dept:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '组织新增', '103', 'F', '', NULL, 'system:dept:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '组织修改', '103', 'F', '', NULL, 'system:dept:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '组织删除', '103', 'F', '', NULL, 'system:dept:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 用户管理 (parent: 104, perms prefix: system:user)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '用户查询', '104', 'F', '', NULL, 'system:user:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '用户新增', '104', 'F', '', NULL, 'system:user:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '用户修改', '104', 'F', '', NULL, 'system:user:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '用户删除', '104', 'F', '', NULL, 'system:user:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 角色管理 (parent: 105, perms prefix: system:role)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '角色查询', '105', 'F', '', NULL, 'system:role:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '角色新增', '105', 'F', '', NULL, 'system:role:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '角色修改', '105', 'F', '', NULL, 'system:role:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '角色删除', '105', 'F', '', NULL, 'system:role:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 菜单管理 (parent: 106, perms prefix: system:menu)
|
||||
-- ============================================================
|
||||
INSERT INTO `sys_menu` (`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
|
||||
VALUES
|
||||
(UUID(), '菜单查询', '106', 'F', '', NULL, 'system:menu:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '菜单新增', '106', 'F', '', NULL, 'system:menu:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '菜单修改', '106', 'F', '', NULL, 'system:menu:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '菜单删除', '106', 'F', '', NULL, 'system:menu:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 站点管理 (parent: 107, perms prefix: system:site)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '站点查询', '107', 'F', '', NULL, 'system:site:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '站点新增', '107', 'F', '', NULL, 'system:site:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '站点修改', '107', 'F', '', NULL, 'system:site:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '站点删除', '107', 'F', '', NULL, 'system:site:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 职位管理 (parent: 108, perms prefix: system:post)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '职位查询', '108', 'F', '', NULL, 'system:post:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '职位新增', '108', 'F', '', NULL, 'system:post:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '职位修改', '108', 'F', '', NULL, 'system:post:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '职位删除', '108', 'F', '', NULL, 'system:post:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 物品单位 (parent: 109, perms prefix: system:unit)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '单位查询', '109', 'F', '', NULL, 'system:unit:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '单位新增', '109', 'F', '', NULL, 'system:unit:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '单位修改', '109', 'F', '', NULL, 'system:unit:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '单位删除', '109', 'F', '', NULL, 'system:unit:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 快递公司 (parent: 110, perms prefix: system:express)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '快递公司查询', '110', 'F', '', NULL, 'system:express:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '快递公司新增', '110', 'F', '', NULL, 'system:express:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '快递公司修改', '110', 'F', '', NULL, 'system:express:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '快递公司删除', '110', 'F', '', NULL, 'system:express:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
|
||||
-- ============================================================
|
||||
-- 质量检测-样本接收 (parent: 70000000000000000000000000000002, perms prefix: biz:sampleReception)
|
||||
-- ============================================================
|
||||
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
|
||||
(UUID(), '样本接收查询', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本接收新增', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:add', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本接收修改', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:edit', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0),
|
||||
(UUID(), '样本接收删除', '70000000000000000000000000000002', 'F', '', NULL, 'biz:sampleReception:delete', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/sql/sys_menu_button_perms.sql
|
||||
git commit -m "feat: 添加 sys_menu 按钮权限(F 类型)初始化 SQL 脚本"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建后端数据权限服务 `data_scope.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/core/data_scope.py`
|
||||
|
||||
- [ ] **Step 1: 编写数据权限服务模块**
|
||||
|
||||
```python
|
||||
"""数据权限过滤服务
|
||||
|
||||
根据用户角色的 data_scope 设置,在 SQLAlchemy query 上叠加过滤条件。
|
||||
支持 5 种数据范围:全部、本部门、本部门及以下、仅本人、自定义。
|
||||
多角色场景取并集。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import (
|
||||
SysRole,
|
||||
SysRoleDataScopeDetail,
|
||||
SysUserRole,
|
||||
SysDept,
|
||||
)
|
||||
|
||||
|
||||
def get_user_dept_id(db: Session, user_id: str) -> Optional[str]:
|
||||
"""获取用户的部门ID"""
|
||||
from app.models.models import SysUser
|
||||
user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||||
return user.dept_id if user else None
|
||||
|
||||
|
||||
def _get_child_dept_ids(db: Session, dept_id: str, tenant_no: str) -> List[str]:
|
||||
"""递归获取部门及所有子部门ID列表"""
|
||||
result = [dept_id]
|
||||
children = (
|
||||
db.query(SysDept.id)
|
||||
.filter(
|
||||
SysDept.parent_id == dept_id,
|
||||
SysDept.del_flag == 0,
|
||||
SysDept.status == "1",
|
||||
SysDept.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for child in children:
|
||||
result.extend(_get_child_dept_ids(db, child.id, tenant_no))
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_user_role_scopes(
|
||||
db: Session, user_id: str, tenant_no: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取用户所有角色的 data_scope 和自定义范围明细。
|
||||
|
||||
Returns:
|
||||
[{"data_scope": "1", "scope_details": [...]}]
|
||||
"""
|
||||
roles = (
|
||||
db.query(SysRole)
|
||||
.join(SysUserRole, SysUserRole.role_id == SysRole.id)
|
||||
.filter(
|
||||
SysUserRole.user_id == user_id,
|
||||
SysUserRole.del_flag == 0,
|
||||
SysUserRole.tenant_no == tenant_no,
|
||||
SysRole.del_flag == 0,
|
||||
SysRole.status == "1",
|
||||
SysRole.tenant_no == tenant_no,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for role in roles:
|
||||
scope_details = (
|
||||
db.query(SysRoleDataScopeDetail)
|
||||
.filter(
|
||||
SysRoleDataScopeDetail.role_id == role.id,
|
||||
SysRoleDataScopeDetail.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
result.append({
|
||||
"data_scope": role.data_scope or "1",
|
||||
"scope_details": [
|
||||
{"scope_type": d.scope_type, "scope_value": d.scope_value}
|
||||
for d in scope_details
|
||||
],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def apply_data_scope(
|
||||
query: Any,
|
||||
model: Any,
|
||||
user_info: dict,
|
||||
db: Session,
|
||||
tenant_no: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""在 SQLAlchemy query 上叠加数据权限过滤条件。
|
||||
|
||||
Args:
|
||||
query: 原始 SQLAlchemy query 对象
|
||||
model: ORM 模型类(需要有 create_dept_id, create_by 字段)
|
||||
user_info: get_current_user 返回的 dict(含 user_id, dept_id 或 region_id)
|
||||
db: 数据库 session
|
||||
tenant_no: 租户编号(可选,用于部门树查询)
|
||||
|
||||
Returns:
|
||||
添加了数据权限过滤条件的 query。如果用户是 super_admin 或无角色,返回原 query。
|
||||
"""
|
||||
# super_admin 不过滤
|
||||
role = user_info.get("role")
|
||||
if role == "super_admin":
|
||||
return query
|
||||
|
||||
user_id = user_info.get("user_id")
|
||||
if not user_id:
|
||||
return query
|
||||
|
||||
tn = tenant_no or "zkxw"
|
||||
|
||||
# 确保模型有必要字段
|
||||
if not hasattr(model, "create_dept_id") and not hasattr(model, "create_by"):
|
||||
return query
|
||||
|
||||
# 获取用户所有角色的数据范围
|
||||
role_scopes = _resolve_user_role_scopes(db, user_id, tn)
|
||||
if not role_scopes:
|
||||
# 无角色 → 不过滤(由菜单守卫控制是否可访问)
|
||||
return query
|
||||
|
||||
# 任一角色 scope=1(全部数据)→ 不过滤
|
||||
if any(rs["data_scope"] == "1" for rs in role_scopes):
|
||||
return query
|
||||
|
||||
# 用户的部门ID
|
||||
user_dept_id = user_info.get("dept_id") or user_info.get("region_id") or get_user_dept_id(db, user_id)
|
||||
|
||||
# 合并所有角色的过滤范围(并集)
|
||||
dept_ids: Set[str] = set()
|
||||
user_ids: Set[str] = set()
|
||||
|
||||
for rs in role_scopes:
|
||||
ds = rs["data_scope"]
|
||||
if ds == "2":
|
||||
# 本部门
|
||||
if user_dept_id:
|
||||
dept_ids.add(user_dept_id)
|
||||
elif ds == "3":
|
||||
# 本部门及以下
|
||||
if user_dept_id:
|
||||
dept_ids.update(_get_child_dept_ids(db, user_dept_id, tn))
|
||||
elif ds == "4":
|
||||
# 仅本人
|
||||
user_ids.add(user_id)
|
||||
elif ds == "5":
|
||||
# 自定义
|
||||
for detail in rs["scope_details"]:
|
||||
val = detail["scope_value"]
|
||||
if detail["scope_type"] == "dept":
|
||||
dept_ids.add(val)
|
||||
elif detail["scope_type"] == "user":
|
||||
user_ids.add(val)
|
||||
|
||||
# 构建过滤条件
|
||||
conditions = []
|
||||
has_dept_field = hasattr(model, "create_dept_id")
|
||||
has_create_by_field = hasattr(model, "create_by")
|
||||
|
||||
if dept_ids and has_dept_field:
|
||||
conditions.append(model.create_dept_id.in_(dept_ids))
|
||||
if user_ids and has_create_by_field:
|
||||
conditions.append(model.create_by.in_(user_ids))
|
||||
|
||||
if not conditions:
|
||||
# 没有任何有效范围 → 退回不过滤
|
||||
return query
|
||||
|
||||
# 多条件取 OR(并集语义)
|
||||
if len(conditions) == 1:
|
||||
return query.filter(conditions[0])
|
||||
return query.filter(or_(*conditions))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/core/data_scope.py
|
||||
git commit -m "feat: 添加数据权限过滤服务 data_scope.py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 修复 admin 无权限码安全漏洞
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/public.py` (lines 272-279)
|
||||
|
||||
- [ ] **Step 1: 修改 public.py 中 menu-codes 端点**
|
||||
|
||||
将 `backend/app/api/public.py` 第 272-279 行的代码:
|
||||
|
||||
```python
|
||||
if role == "admin" and not menu_codes:
|
||||
return success_response(
|
||||
{
|
||||
"isSuperAdmin": False,
|
||||
"all": True,
|
||||
"menuCodes": [],
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
# admin 无权限码时返回空列表(不再赋予全部权限)
|
||||
# 安全修复:空权限码 = 无菜单访问权限
|
||||
```
|
||||
|
||||
即删除整个 `if role == "admin" and not menu_codes:` 分支。后续的 `return success_response(...)` 会自动处理,返回 `all: False, menuCodes: []`。
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/public.py
|
||||
git commit -m "fix: 修复 admin 无权限码时返回 all:true 的安全漏洞"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 后端业务路由添加菜单权限守卫 + 数据权限过滤
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/basic_info.py`
|
||||
- Modify: `backend/app/api/operations.py`
|
||||
- Modify: `backend/app/api/collection_protocol.py`
|
||||
- Modify: `backend/app/api/sample_reception.py`
|
||||
- Modify: `backend/app/api/admin.py` (用户/角色管理部分)
|
||||
|
||||
**原则:**
|
||||
- 每个路由文件的 import 中添加 `from app.core.menu_guard import require_menu_code` 和 `from app.core.data_scope import apply_data_scope`
|
||||
- 将 `Depends(require_admin_or_super_admin)` 替换为 `Depends(require_menu_code("xxx:list"))`(list 端点)、`Depends(require_menu_code("xxx:add"))`(create 端点)等
|
||||
- list 端点在查询链中调用 `apply_data_scope(query, Model, user, db, tenant_no)`
|
||||
|
||||
- [ ] **Step 1: 修改 `basic_info.py`**
|
||||
|
||||
1. 替换 import:`from app.core.menu_guard import require_admin_or_super_admin` → `from app.core.menu_guard import require_menu_code`
|
||||
2. 添加 import:`from app.core.data_scope import apply_data_scope`
|
||||
3. 客户管理路由:
|
||||
- `list_customers`: `Depends(require_menu_code("biz:customer:list"))` + 查询添加 `apply_data_scope`
|
||||
- `create_customer`: `Depends(require_menu_code("biz:customer:add"))`
|
||||
- `update_customer`: `Depends(require_menu_code("biz:customer:edit"))`
|
||||
- `delete_customer`: `Depends(require_menu_code("biz:customer:delete"))`
|
||||
4. 细胞样本类型路由:同理,perms 为 `biz:cellSampleType:*`
|
||||
5. 采集地区路由:同理,perms 为 `biz:collectionRegion:*`
|
||||
6. 采集协议号路由:同理,perms 为 `biz:collectionProtocolNo:*`
|
||||
7. 样本编码路由:同理,perms 为 `biz:sampleCode:*`
|
||||
|
||||
- [ ] **Step 2: 修改 `operations.py`**
|
||||
|
||||
洁净室路由添加 `require_menu_code("biz:cleanRoom:*")` + `apply_data_scope`。仓储设施、展厅预约同理。
|
||||
|
||||
注意:operations.py 中的业务目前没有对应的 `sys_menu.perms`,需要根据 SQL 中是否有对应菜单决定。如果 SQL 中尚无菜单记录,则保持 `require_admin_or_super_admin` 不变,待后续添加菜单后再改。
|
||||
|
||||
- [ ] **Step 3: 修改 `collection_protocol.py`**
|
||||
|
||||
采集协议路由添加 `require_menu_code("biz:collectionProtocol:*")` + `apply_data_scope`。
|
||||
|
||||
注意:SQL 中当前没有 `biz:collectionProtocol:list` 的菜单记录。如果此模块菜单尚未配置,保持 `require_admin_or_super_admin`。
|
||||
|
||||
- [ ] **Step 4: 修改 `sample_reception.py`**
|
||||
|
||||
样本接收路由添加 `require_menu_code("biz:sampleReception:*")` + `apply_data_scope`。
|
||||
|
||||
- [ ] **Step 5: 修改 `admin.py`**
|
||||
|
||||
用户管理、角色管理、字典管理等系统管理接口添加 `require_menu_code`:
|
||||
- 用户 CRUD:`system:user:list/add/edit/delete`
|
||||
- 角色 CRUD:`system:role:list/add/edit/delete`
|
||||
- 租户 CRUD:保持 `require_admin_or_super_admin`(仅 super_admin)
|
||||
- 字典管理:`system:dict:list/add/edit/delete`
|
||||
- 组织管理:`system:dept:list/add/edit/delete`
|
||||
- 菜单管理:`system:menu:list/add/edit/delete`
|
||||
|
||||
用户列表添加 `apply_data_scope` 过滤。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/basic_info.py backend/app/api/operations.py backend/app/api/collection_protocol.py backend/app/api/sample_reception.py backend/app/api/admin.py
|
||||
git commit -m "feat: 所有业务路由添加菜单权限守卫和数据权限过滤"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端用户管理添加角色选择器
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/system/user/index.vue`
|
||||
|
||||
- [ ] **Step 1: 在 formData 中添加 roleIds 字段**
|
||||
|
||||
```typescript
|
||||
const formData = reactive({
|
||||
userNo: '',
|
||||
nickName: '',
|
||||
password: '',
|
||||
deptId: '',
|
||||
email: '',
|
||||
phoneNo: '',
|
||||
sex: '0',
|
||||
avatar: '',
|
||||
status: '1',
|
||||
orderNum: 0,
|
||||
remark: '',
|
||||
roleIds: [] as string[], // 新增
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加角色选项加载逻辑**
|
||||
|
||||
```typescript
|
||||
import { fetchRoleOptions } from '@/api/admin'
|
||||
|
||||
const roleOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
async function loadRoleOptions() {
|
||||
try {
|
||||
roleOptions.value = await fetchRoleOptions()
|
||||
} catch (e) {
|
||||
console.error('加载角色选项失败:', e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在弹窗表单中添加角色多选下拉框**
|
||||
|
||||
在 `<div class="form-grid-2">` 中,"状态" 选择器后面、"备注" 前面添加:
|
||||
|
||||
```html
|
||||
<el-form-item label="角色" style="grid-column: 1 / -1">
|
||||
<el-select
|
||||
v-model="formData.roleIds"
|
||||
multiple
|
||||
placeholder="请选择角色"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in roleOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 handleSubmit 中传递 roleIds**
|
||||
|
||||
```typescript
|
||||
// create 分支
|
||||
await fetchCreateUser({
|
||||
...其他字段,
|
||||
roleIds: formData.roleIds.length ? formData.roleIds : undefined,
|
||||
})
|
||||
|
||||
// update 分支
|
||||
await fetchUpdateUser(editingId.value, {
|
||||
...其他字段,
|
||||
roleIds: formData.roleIds.length ? formData.roleIds : undefined,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 handleEdit 中回填 roleIds**
|
||||
|
||||
编辑时从 `row.roleIds` 回填:
|
||||
|
||||
```typescript
|
||||
function handleEdit(row: Api.User.UserItem) {
|
||||
// ... 现有字段回填 ...
|
||||
formData.roleIds = (row as any).roleIds || [] // 后端 get_user_detail 返回 roleIds
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 在 resetForm 和 onMounted 中处理**
|
||||
|
||||
```typescript
|
||||
function resetForm() {
|
||||
// ... 现有重置 ...
|
||||
formData.roleIds = []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDeptTree()
|
||||
loadData()
|
||||
loadRoleOptions()
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/system/user/index.vue
|
||||
git commit -m "feat: 用户管理弹窗添加角色多选下拉框"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 前端业务页面按钮添加权限控制
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/basic-info/customer/index.vue`
|
||||
- Modify: `web-admin/src/views/basic-info/cell-sample-type/index.vue`
|
||||
- Modify: `web-admin/src/views/basic-info/collection-region/index.vue`
|
||||
- Modify: `web-admin/src/views/basic-info/collection-protocol-no/index.vue`
|
||||
- Modify: `web-admin/src/views/basic-info/sample-code/index.vue`
|
||||
|
||||
**每个页面的修改模式相同:**
|
||||
|
||||
- [ ] **Step 1: 在 `<script setup>` 中导入 useAuth**
|
||||
|
||||
```typescript
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
const { hasAuth } = useAuth()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在模板按钮上添加 v-if 权限控制**
|
||||
|
||||
工具栏"新增"按钮:
|
||||
```html
|
||||
<el-button v-if="hasAuth('add')" type="primary" @click="handleAdd">新增</el-button>
|
||||
```
|
||||
|
||||
表格操作列按钮:
|
||||
```html
|
||||
<el-button v-if="hasAuth('edit')" link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="hasAuth('delete')" link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 对所有 5 个页面重复以上修改**
|
||||
|
||||
每个页面的按钮权限标识通过 `hasAuth('add')` / `hasAuth('edit')` / `hasAuth('delete')` 控制,对应 `sys_menu` 中 F 类型按钮记录的 `perms` 尾段。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/basic-info/
|
||||
git commit -m "feat: 基础信息管理页面按钮添加 hasAuth 权限控制"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自检清单
|
||||
|
||||
- [x] **Spec 覆盖**:Task 1 覆盖 F 类型菜单数据;Task 2 覆盖数据权限服务;Task 3 覆盖安全漏洞修复;Task 4 覆盖后端路由守卫;Task 5 覆盖前端角色选择器;Task 6 覆盖前端按钮权限
|
||||
- [x] **Placeholder 扫描**:无 TBD、TODO、"implement later"
|
||||
- [x] **类型一致性**:`apply_data_scope` 函数签名在 Task 2 定义,Task 4 使用时参数匹配
|
||||
- [x] **文件路径准确**:所有路径基于实际代码库结构
|
||||
@@ -0,0 +1,861 @@
|
||||
# 采集协议单查看弹窗 方案D风格改造 实现计划
|
||||
|
||||
> **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:** 将 ProtocolViewDialog.vue 的查看弹窗 UI 改造为方案D风格(KPI摘要条 + 圆角卡片分区 + 🤱👨👶子卡片 + 3列网格),同时保留所有现有信息项不遗漏。
|
||||
|
||||
**Architecture:** 单文件改造,只改 `ProtocolViewDialog.vue` 的 `<template>` 和 `<style>` 部分。`<script>` 逻辑层不变(数据获取、格式化函数、computed 全部保留)。三个页面(采集协议单、缴费信息、采集协议单审核)复用同一个组件,改一个文件即全部生效。
|
||||
|
||||
**Tech Stack:** Vue 3 + Element Plus + SCSS (scoped)。组件内纯 CSS 改造,不引入新依赖。
|
||||
|
||||
**关键约束:**
|
||||
- 所有现有信息项不可遗漏(字段对齐现有 view-kv-grid 中的每个 dt/dd)
|
||||
- 保留 `showAuditHistory` prop 的条件展示逻辑
|
||||
- 保留文件预览/下载功能(ProtocolAttachmentReadonly、openPreview、downloadFile)
|
||||
- 保留 sticky tab 导航锚点
|
||||
- 保留附件卡片样式(ProtocolAttachmentReadonly 组件不变)
|
||||
- 配色严格遵循 Fresh Mint 色板:primary #10b981,dark #059669,禁止使用小程序色 #0a9f6d
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` | **修改** | template + style 全面改造,script 不动 |
|
||||
|
||||
---
|
||||
|
||||
## 参考设计对照表
|
||||
|
||||
### 现有字段 → 方案D映射
|
||||
|
||||
**Tab 1: 基本信息**
|
||||
| 现有字段 | 方案D位置 |
|
||||
|---------|----------|
|
||||
| 协议号 | KPI摘要卡 + 基本信息卡片 |
|
||||
| 协议状态 | KPI摘要卡(状态点) + 基本信息卡片(badge) |
|
||||
| 登记日期 | KPI摘要卡 + 基本信息卡片 |
|
||||
| 采集专员 | 基本信息卡片 |
|
||||
| 创建时间 | 基本信息卡片 |
|
||||
| 协议附件 | 基本信息卡片 — 附件行 |
|
||||
| 备注 | 基本信息卡片 — 备注行 |
|
||||
|
||||
**Tab 2: 客户信息**
|
||||
| 现有字段 | 方案D位置 |
|
||||
|---------|----------|
|
||||
| 母亲: 关联客户/姓名/电话/身份证/出生年月/民族/住址 | 🤱 母亲信息子卡片 (2列grid) |
|
||||
| 父亲: 姓名/电话/身份证 | 👨 父亲信息子卡片 (2列grid) |
|
||||
| 新生儿: 姓名/身份证/分娩时间/性别/分娩方式/胎次/体重/评分1min/评分5min | 👶 新生儿信息子卡片 (2列grid) |
|
||||
|
||||
**Tab 3: 采集信息**
|
||||
| 现有字段 | 方案D位置 |
|
||||
|---------|----------|
|
||||
| 采集地区/采集医院/采集医护/销售人员/样本采集时间/母血采集时间 | 白色卡片 — 3列grid |
|
||||
|
||||
**Tab 4: 样本及运输信息**
|
||||
| 现有字段 | 方案D位置 |
|
||||
|---------|----------|
|
||||
| 样本表格(11列) | 白色卡片 — el-table 保留,样式微调 |
|
||||
|
||||
**Tab 5: 缴费信息**
|
||||
| 现有字段 | 方案D位置 |
|
||||
|---------|----------|
|
||||
| 缴费模式/合同金额/分期相关字段 | 灰色卡片 — 缴费主表指标卡 |
|
||||
| 缴费明细卡片 | 灰色卡片 — 明细卡片区(含退费场景) |
|
||||
| 缴费流程轨道 | 灰色卡片 — mini-flow 横向节点 |
|
||||
|
||||
**Tab 6: 审核记录** (条件展示)
|
||||
| 现有字段 | 方案D位置 |
|
||||
|---------|----------|
|
||||
| 审核记录表格 | 白色卡片 — el-table 保留 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 改造 template — 添加 KPI 摘要条 + 整体结构
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (template 部分)
|
||||
|
||||
- [ ] **Step 1: 在 `<div class="protocol-view-body">` 内、`<template v-if="bundle">` 之前,添加 KPI 摘要条**
|
||||
|
||||
用 4 个 `kpi-card` 替代之前无顶部摘要的设计。KPI 条显示:协议编号、协议状态(带状态点)、母亲姓名、登记日期。所有值来自 `protocol` 和 `partyCustomer` computed。
|
||||
|
||||
```html
|
||||
<!-- KPI Summary Strip -->
|
||||
<div v-if="bundle" class="kpi-strip">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">协议编号</div>
|
||||
<div class="kpi-value">{{ dash(protocol?.protocolNo) }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">协议状态</div>
|
||||
<div class="kpi-value">
|
||||
<span class="status-dot" :class="statusDotClass(String(protocol?.protocolStatus || ''))" />
|
||||
{{ protocolStatusLabel(String(protocol?.protocolStatus || '')) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">母亲姓名</div>
|
||||
<div class="kpi-value">{{ dash(partyCustomer?.motherName) }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">登记日期</div>
|
||||
<div class="kpi-value">{{ formatYmd(protocol?.registerDate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 script 中添加 `statusDotClass` 函数**
|
||||
|
||||
在现有 `protocolStatusLabel` 函数附近添加:
|
||||
|
||||
```typescript
|
||||
function statusDotClass(s: string): string {
|
||||
const m: Record<string, string> = {
|
||||
dsx: 'pending',
|
||||
sxz: 'active',
|
||||
ygq: 'expired',
|
||||
yzf: 'void',
|
||||
zky: 'research'
|
||||
}
|
||||
return m[s] || ''
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 将 `<template v-if="bundle">` 内的 6 个 section 改为卡片包裹**
|
||||
|
||||
每个 `form-tab-pane` 改为 `section-card` 结构。保留所有 `id="view-section-*"` 用于滚动锚点。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 改造基本信息 section
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (基本信息模板)
|
||||
|
||||
- [ ] **Step 1: 替换基本信息区域的 HTML 结构**
|
||||
|
||||
将现有的 `<div id="view-section-basic" class="form-tab-pane">` 替换为方案D卡片结构:
|
||||
|
||||
```html
|
||||
<!-- 基本信息 -->
|
||||
<div id="view-section-basic" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>基本信息</h3></div>
|
||||
<div class="data-grid data-grid--3">
|
||||
<div class="data-item">
|
||||
<div class="data-label">协议号</div>
|
||||
<div class="data-value">{{ dash(protocol?.protocolNo) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">协议状态</div>
|
||||
<div class="data-value">
|
||||
<span class="badge" :class="statusBadgeClass(String(protocol?.protocolStatus || ''))">
|
||||
{{ protocolStatusLabel(String(protocol?.protocolStatus || '')) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">登记日期</div>
|
||||
<div class="data-value">{{ formatYmd(protocol?.registerDate) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">采集专员</div>
|
||||
<div class="data-value">{{ collectorDisplay }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">创建时间</div>
|
||||
<div class="data-value">{{ dash(protocol?.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 附件 -->
|
||||
<div class="attachment-row" v-if="protocolAttachments.length">
|
||||
<div class="data-label">协议附件</div>
|
||||
<ProtocolAttachmentReadonly
|
||||
:files="protocolAttachments"
|
||||
@preview="openPreview"
|
||||
@download="downloadFile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div class="remark-row" v-if="protocol?.remark">
|
||||
<div class="data-item data-item--full">
|
||||
<div class="data-label">备注</div>
|
||||
<div class="data-value data-value--muted">{{ dash(protocol?.remark) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 script 中添加 `statusBadgeClass` 函数**
|
||||
|
||||
```typescript
|
||||
function statusBadgeClass(s: string): string {
|
||||
const m: Record<string, string> = {
|
||||
dsx: 'badge--amber',
|
||||
sxz: 'badge--green',
|
||||
ygq: 'badge--gray',
|
||||
yzf: 'badge--red',
|
||||
zky: 'badge--purple'
|
||||
}
|
||||
return m[s] || 'badge--gray'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 改造客户信息 section — 🤱👨👶 子卡片
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (客户信息模板)
|
||||
|
||||
- [ ] **Step 1: 替换客户信息区域的 HTML 结构**
|
||||
|
||||
将现有 `view-section-party` 的 `form-tab-pane` + 虚线分隔 + `detail-section-title` 改为绿色卡片 + 3 列子卡片:
|
||||
|
||||
```html
|
||||
<!-- 客户信息 -->
|
||||
<div id="view-section-party" class="section-card section-card--green">
|
||||
<div class="section-title"><h3>客户信息</h3></div>
|
||||
<div class="client-sub-cards">
|
||||
<!-- 🤱 母亲信息 -->
|
||||
<div class="client-sub-card">
|
||||
<div class="sub-card-header">
|
||||
<span class="sub-card-icon">🤱</span>
|
||||
<h4>母亲信息</h4>
|
||||
</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">关联客户</div>
|
||||
<div class="data-value">{{ partyCustomer?.customerId ? dash(partyCustomer?.motherName) : '—' }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">姓名</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.motherName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">联系电话</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.motherPhone) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">身份证号</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.motherIdCard) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">出生年月</div>
|
||||
<div class="data-value">{{ formatYm(partyCustomer?.motherBirthYm) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">民族</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.motherEthnic) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-card-full-row">
|
||||
<div class="data-label">家庭住址</div>
|
||||
<div class="data-value data-value--muted">{{ dash(partyCustomer?.motherAddress) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 👨 父亲信息 -->
|
||||
<div class="client-sub-card">
|
||||
<div class="sub-card-header">
|
||||
<span class="sub-card-icon">👨</span>
|
||||
<h4>父亲信息</h4>
|
||||
</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">姓名</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.fatherName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">电话</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.fatherPhone) }}</div>
|
||||
</div>
|
||||
<div class="data-item data-item--full">
|
||||
<div class="data-label">身份证号</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.fatherIdCard) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 👶 新生儿信息 -->
|
||||
<div class="client-sub-card">
|
||||
<div class="sub-card-header">
|
||||
<span class="sub-card-icon">👶</span>
|
||||
<h4>新生儿信息</h4>
|
||||
</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">姓名</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.newbornName) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">身份证</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.newbornIdCard) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">分娩时间</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.newbornDeliveryTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">性别</div>
|
||||
<div class="data-value">{{ newbornSexLabel(String(partyCustomer?.newbornSex || '')) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">分娩方式</div>
|
||||
<div class="data-value">{{ deliveryModeLabel(String(partyCustomer?.deliveryMode || '')) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">胎次</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.birthOrder) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">体重(克)</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.newbornWeightGram) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">评分1分钟</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.apgar1min) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">评分5分钟</div>
|
||||
<div class="data-value">{{ dash(partyCustomer?.apgar5min) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 改造采集信息 section
|
||||
|
||||
- [ ] **Step 1: 替换采集信息区域的 HTML 结构**
|
||||
|
||||
```html
|
||||
<!-- 采集信息 -->
|
||||
<div id="view-section-collection" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>采集信息</h3></div>
|
||||
<div class="data-grid data-grid--3">
|
||||
<div class="data-item">
|
||||
<div class="data-label">采集地区</div>
|
||||
<div class="data-value">{{ regionDisplay }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">采集医院</div>
|
||||
<div class="data-value">{{ hospitalDisplay }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">采集医护</div>
|
||||
<div class="data-value">{{ dash(collectionInfo?.medicalStaff) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">销售人员</div>
|
||||
<div class="data-value">{{ salesDisplay }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">样本采集时间</div>
|
||||
<div class="data-value">{{ dash(collectionInfo?.sampleCollectTime) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">母血采集时间</div>
|
||||
<div class="data-value">{{ dash(collectionInfo?.maternalBloodCollectTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 改造样本及运输信息 section
|
||||
|
||||
- [ ] **Step 1: 包裹样本区域为白色卡片**
|
||||
|
||||
保留 el-table 和 el-empty,只改外层容器从 `form-tab-pane` 改为 `section-card section-card--white`:
|
||||
|
||||
```html
|
||||
<!-- 样本及运输信息 -->
|
||||
<div id="view-section-sample" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>样本及运输信息</h3></div>
|
||||
<el-table :data="samples" row-key="id" stripe border size="small" class="sub-view-table">
|
||||
<!-- 保留所有现有 el-table-column 不变 -->
|
||||
</el-table>
|
||||
<el-empty v-if="!samples.length" description="暂无样本" :image-size="48" />
|
||||
</div>
|
||||
```
|
||||
|
||||
表格列完全保持原样(11列:样本类型/编码/采集量/单位/价格/存储年限/状态/运输类型/目的站点/预计到达/快递携带/备注),只改外层包裹。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 改造缴费信息 section — 卡片 + 明细 + 退费流程
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (缴费信息模板)
|
||||
|
||||
- [ ] **Step 1: 替换缴费信息区域为灰色卡片 + 指标卡 + 明细卡片**
|
||||
|
||||
将现有的 `view-section-payment` 改造。缴费主表改为 4 个 `payment-metric` 指标卡。缴费明细卡片保留现有卡片结构但加入退费样式(红色/琥珀色左边框、退费详情区、mini-flow 横向节点)。**所有现有字段必须保留**。
|
||||
|
||||
```html
|
||||
<!-- 缴费信息 -->
|
||||
<div id="view-section-payment" class="section-card section-card--gray">
|
||||
<div class="section-title"><h3>缴费信息</h3></div>
|
||||
|
||||
<!-- 缴费指标 -->
|
||||
<div class="payment-summary">
|
||||
<div class="payment-metric">
|
||||
<div class="pm-label">缴费模式</div>
|
||||
<div class="pm-value">{{ payModeLabel(String(payment?.payMode || '')) }}</div>
|
||||
</div>
|
||||
<div class="payment-metric">
|
||||
<div class="pm-label">合同金额</div>
|
||||
<div class="pm-value pm-value--amount">{{ dash(payment?.contractAmount) }}</div>
|
||||
</div>
|
||||
<template v-if="payment?.payMode === 'dj'">
|
||||
<div class="payment-metric">
|
||||
<div class="pm-label">趸交截止时间</div>
|
||||
<div class="pm-value">{{ formatYmd(payment?.lumpSumDeadline) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="payment?.payMode === 'fq'">
|
||||
<div class="payment-metric">
|
||||
<div class="pm-label">分期期数</div>
|
||||
<div class="pm-value">{{ dash(payment?.installmentCount) }}</div>
|
||||
</div>
|
||||
<div class="payment-metric">
|
||||
<div class="pm-label">每期间隔(月)</div>
|
||||
<div class="pm-value">{{ dash(payment?.installmentIntervalMonths) }}</div>
|
||||
</div>
|
||||
<div class="payment-metric">
|
||||
<div class="pm-label">分期开始时间</div>
|
||||
<div class="pm-value">{{ formatYmd(payment?.installmentStartDate) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 缴费明细 -->
|
||||
<div class="section-subtitle">缴费明细</div>
|
||||
<div class="payment-details">
|
||||
<div
|
||||
v-for="(detail, dIdx) in paymentDetails"
|
||||
:key="detail.id || dIdx"
|
||||
class="payment-detail-card"
|
||||
:class="{
|
||||
'payment-detail-card--refunded': String(detail.payStatus) === 'ytf',
|
||||
'payment-detail-card--refund-pending': String(detail.payStatus) === 'tfqrz'
|
||||
}"
|
||||
>
|
||||
<!-- 保留现有 card header/body 结构,包含所有字段 -->
|
||||
<!-- 保留 payment-flow 横向节点 -->
|
||||
<!-- 保留所有附件展示 -->
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无缴费明细" :image-size="48" />
|
||||
</div>
|
||||
```
|
||||
|
||||
注意:缴费明细内的每一个字段(应缴费金额、转账方式、转账金额、转账时间、缴费状态、应缴费时间、转账凭证附件)和退费流程节点(缴费财务确认、退费申请、财务退费确认)**全部保留**,只改外层容器样式。
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 改造审核记录 section
|
||||
|
||||
- [ ] **Step 1: 包裹审核记录为白色卡片**
|
||||
|
||||
保留 el-table,只改外层容器:
|
||||
|
||||
```html
|
||||
<div v-if="showAuditHistory" id="view-section-audit" class="section-card section-card--white">
|
||||
<div class="section-title"><h3>审核记录</h3></div>
|
||||
<el-table ...>
|
||||
<!-- 保留所有现有列不变 -->
|
||||
</el-table>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 替换整个 `<style>` 块为方案D风格 CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue` (style 部分)
|
||||
|
||||
- [ ] **Step 1: 删除现有 `<style scoped lang="scss">` 中的所有旧样式**
|
||||
|
||||
- [ ] **Step 2: 写入方案D风格的新 CSS**
|
||||
|
||||
以下是需要写入的完整新样式体系(从 demo-protocol-view-d.html 提取,适配 Vue scoped + Element Plus 共存):
|
||||
|
||||
**核心样式类:**
|
||||
|
||||
```scss
|
||||
/* ── Dialog Body ── */
|
||||
.protocol-view-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ── Sticky Tab Bar ── */
|
||||
.detail-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: #f5f7fa;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
width: fit-content;
|
||||
isolation: isolate;
|
||||
}
|
||||
.detail-tab {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
transition: all 0.3s;
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #059669;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
&:hover:not(.active) {
|
||||
color: #059669;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ KPI Summary Strip ═══ */
|
||||
.kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.kpi-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
border-left: 3px solid #10b981;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0;
|
||||
width: 60px; height: 60px;
|
||||
background: radial-gradient(circle at top right, rgba(16,185,129,0.06), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16,185,129,0.1);
|
||||
}
|
||||
}
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
&.active {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16,185,129,0.2);
|
||||
}
|
||||
&.pending { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,0.2); }
|
||||
&.expired { background: #94a3b8; }
|
||||
&.void { background: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.2); }
|
||||
&.research { background: #7c3aed; box-shadow: 0 0 0 3px rgba(124,58,237,0.2); }
|
||||
}
|
||||
|
||||
/* ═══ Section Cards ═══ */
|
||||
.section-card {
|
||||
border-radius: 12px;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
&--white { background: #fff; }
|
||||
&--green { background: #f0fdf4; }
|
||||
&--gray { background: #f8fafc; }
|
||||
}
|
||||
|
||||
/* Section Title */
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
background: #10b981;
|
||||
border-radius: 2px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin: 16px 0 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
/* ── Data Grid ── */
|
||||
.data-grid {
|
||||
display: grid;
|
||||
gap: 14px 20px;
|
||||
&--3 { grid-template-columns: repeat(3, 1fr); }
|
||||
&--2 { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.data-item {
|
||||
// no special styling needed
|
||||
}
|
||||
.data-item--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.data-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.data-value {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
&--muted {
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Badges ── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge--green { background: #ecfdf5; color: #059669; }
|
||||
.badge--amber { background: #fffbeb; color: #d97706; }
|
||||
.badge--gray { background: #f1f5f9; color: #64748b; }
|
||||
.badge--red { background: #fef2f2; color: #dc2626; }
|
||||
.badge--purple { background: #faf5ff; color: #7c3aed; }
|
||||
|
||||
/* ── Attachment Row ── */
|
||||
.attachment-row {
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Remark Row ── */
|
||||
.remark-row {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
/* ═══ Client Sub Cards ═══ */
|
||||
.client-sub-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.client-sub-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
}
|
||||
.sub-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
.sub-card-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
.sub-card-full-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* ═══ Table Styles ═══ */
|
||||
.sub-view-table {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ═══ Payment Section ═══ */
|
||||
.payment-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.payment-metric {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.pm-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.pm-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
&--amount { color: #059669; }
|
||||
}
|
||||
|
||||
.payment-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.payment-detail-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
&--refunded {
|
||||
border-left: 3px solid #dc2626;
|
||||
}
|
||||
&--refund-pending {
|
||||
border-left: 3px solid #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
/* 保留现有的 payment-flow 样式 */
|
||||
.payment-flow { /* ...existing... */ }
|
||||
|
||||
/* ═══ Responsive ═══ */
|
||||
@media (max-width: 1200px) {
|
||||
.data-grid--3,
|
||||
.data-grid--2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.client-sub-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.kpi-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.payment-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 验证所有字段无遗漏 + 弹窗可正常工作
|
||||
|
||||
**Files:**
|
||||
- Verify: `web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue`
|
||||
|
||||
- [ ] **Step 1: 字段清单核查**
|
||||
|
||||
逐项对照现有 `view-kv-grid` 中的 dt/dd,确保新模板中每个字段都存在:
|
||||
|
||||
基本信息(5+附件+备注): ✅ 协议号/状态/登记日期/采集专员/创建时间/附件/备注
|
||||
客户-母亲(7): ✅ 关联客户/姓名/电话/身份证/出生年月/民族/住址
|
||||
客户-父亲(3): ✅ 姓名/电话/身份证
|
||||
客户-新生儿(9): ✅ 姓名/身份证/分娩时间/性别/分娩方式/胎次/体重/评分1/评分5
|
||||
采集信息(6): ✅ 采集地区/医院/医护/销售/样本采集时间/母血采集时间
|
||||
样本表格(11列): ✅ 类型/编码/采集量/单位/价格/存储年限/状态/运输类型/目的站点/预计到达/快递携带/备注
|
||||
缴费主表(4-6): ✅ 缴费模式/合同金额/趸交截止|分期期数+间隔+开始时间
|
||||
缴费明细(每条6-8): ✅ 应缴金额/转账方式/转账金额/转账时间/缴费状态/应缴费时间/转账凭证附件
|
||||
缴费流程节点: ✅ 缴费财务确认/退费申请/财务退费确认(含金额/备注/附件)
|
||||
审核记录(5列): ✅ 序号/审核时间/审核状态/审核备注/审核人
|
||||
|
||||
- [ ] **Step 2: 运行 dev 服务器验证**
|
||||
|
||||
```bash
|
||||
cd web-admin && pnpm dev
|
||||
```
|
||||
|
||||
打开 `/#/demo/protocol` 页面,点击「查看」按钮,验证:
|
||||
1. KPI 摘要条正确显示
|
||||
2. 卡片分区布局正确
|
||||
3. 🤱👨👶 子卡片渲染正常
|
||||
4. 所有字段数据显示正常
|
||||
5. 附件可预览/下载
|
||||
6. 退费状态卡片有红色/琥珀色边框
|
||||
7. 审核记录 tab 正确显示(采集协议单审核页)
|
||||
8. sticky tab 导航可滚动定位
|
||||
|
||||
- [ ] **Step 3: 在缴费信息页验证**
|
||||
|
||||
打开 `/#/cell-storage/payment-info` 页面,点击「查看」,确认弹窗正确渲染。
|
||||
|
||||
- [ ] **Step 4: 在审核页验证**
|
||||
|
||||
打开 `/#/cell-storage/protocol-audit` 页面,点击「查看」,确认审核记录 tab 显示。
|
||||
|
||||
- [ ] **Step 5: 修复 LSP 诊断**
|
||||
|
||||
```bash
|
||||
# 检查是否有 TypeScript 错误
|
||||
pnpm build:typecheck
|
||||
```
|
||||
@@ -0,0 +1,242 @@
|
||||
# 权限系统全面修复设计文档
|
||||
|
||||
## 背景
|
||||
|
||||
SQL 模型已定义完整的权限体系(sys_menu, sys_role, sys_role_menu, sys_role_data_scope_detail, sys_user_role),但后端和前端只实现了 CRUD 和 UI 配置,**权限执行层几乎为空**。本次修复补全所有缺失的权限执行逻辑。
|
||||
|
||||
## 修复范围
|
||||
|
||||
| # | 模块 | 优先级 | 描述 |
|
||||
|---|------|--------|------|
|
||||
| 1 | 后端菜单权限守卫 | P0 | 将已有的 `require_menu_code()` 应用到所有业务路由 |
|
||||
| 2 | 后端数据权限过滤 | P0 | 创建 `data_scope` 服务,在业务查询中按部门/用户/自定义范围过滤 |
|
||||
| 3 | 前端用户角色选择器 | P1 | 用户管理弹窗中添加角色多选下拉框 |
|
||||
| 4 | 前端按钮级权限 | P1 | 业务页面按钮添加 `v-auth` / `hasAuth` 权限控制 |
|
||||
| 5 | admin 无权限码安全漏洞 | P2 | 修复空权限码返回 `{all: true}` 的问题 |
|
||||
| 6 | 多角色权限合并 | P2 | 后端菜单码已天然合并;数据权限需实现并集逻辑 |
|
||||
|
||||
---
|
||||
|
||||
## 模块 1:后端菜单权限守卫
|
||||
|
||||
### 现状
|
||||
- `app/core/menu_guard.py` 已定义 `require_menu_code(menu_code)` 函数
|
||||
- 函数逻辑:super_admin 放行 → 查用户菜单权限码 → 不匹配则 403
|
||||
- **但从未在任何路由中 import 或使用**
|
||||
|
||||
### 修改方案
|
||||
|
||||
在业务路由的每个端点上叠加 `Depends(require_menu_code("xxx"))` 作为第二层守卫。
|
||||
|
||||
**原则**:
|
||||
- 系统管理接口(租户、角色、菜单 CRUD)保持 `require_admin_or_super_admin`,仅 super_admin 可操作
|
||||
- 业务接口(客户、订单、采集协议等)使用 `require_menu_code`
|
||||
- 列表接口用 `xxx:list`,创建用 `xxx:add`,更新用 `xxx:edit`,删除用 `xxx:delete`
|
||||
- `require_menu_code` 内部已先调用 `require_admin_or_super_admin`,所以路由只需一个 Depends
|
||||
|
||||
**影响文件**:
|
||||
- `backend/app/api/basic_info.py` — 客户、细胞样本类型、采集地区、采集协议号、样本编码等
|
||||
- `backend/app/api/operations.py` — 渠道、订单、质检等运营接口
|
||||
- `backend/app/api/collection_protocol.py` — 采集协议
|
||||
- `backend/app/api/sample_reception.py` — 样本接收
|
||||
- `backend/app/api/admin.py` — 仅业务部分(用户管理加 `system:user:list` 等)
|
||||
|
||||
**perms 值来源**:直接使用 `sys_menu` 表中已定义的 `perms` 字段值。
|
||||
|
||||
### 需要新增的 sys_menu 按钮(F)类型记录
|
||||
|
||||
当前 SQL 中 `sys_menu` 大多只有 M(目录) 和 C(页面) 类型。为了让 `require_menu_code` 能精确控制到增删改操作,需要在 SQL 中为每个 C 类型菜单补充 F 类型按钮权限:
|
||||
|
||||
- `xxx:list` — 查看列表
|
||||
- `xxx:add` — 新增
|
||||
- `xxx:edit` — 编辑
|
||||
- `xxx:delete` — 删除
|
||||
|
||||
这些 F 类型记录的 `parent_id` 指向对应的 C 类型菜单。此步骤为 **数据初始化**,需生成 SQL 脚本。
|
||||
|
||||
---
|
||||
|
||||
## 模块 2:后端数据权限过滤
|
||||
|
||||
### 现状
|
||||
- `sys_role.data_scope` 字段存在(1-5 级别)
|
||||
- `sys_role_data_scope_detail` 表和 CRUD 已实现
|
||||
- **没有任何业务查询读取 data_scope 进行过滤**
|
||||
|
||||
### 设计
|
||||
|
||||
#### 新文件:`backend/app/core/data_scope.py`
|
||||
|
||||
```python
|
||||
def apply_data_scope(query, model, user_info: dict, db: Session) -> query:
|
||||
"""在 SQLAlchemy query 上叠加数据权限过滤条件。
|
||||
|
||||
参数:
|
||||
query: 原始 SQLAlchemy query 对象
|
||||
model: ORM 模型类(需要有 create_dept_id, create_by 字段)
|
||||
user_info: get_current_user 返回的 dict(含 user_id, dept_id)
|
||||
db: 数据库 session
|
||||
|
||||
返回:
|
||||
添加了数据权限过滤条件的 query
|
||||
"""
|
||||
```
|
||||
|
||||
#### 过滤逻辑
|
||||
|
||||
| data_scope | 条件 |
|
||||
|------------|------|
|
||||
| `1` 全部数据 | 无附加过滤 |
|
||||
| `2` 本部门 | `model.create_dept_id == user.dept_id` |
|
||||
| `3` 本部门及以下 | `model.create_dept_id IN (user.dept_id + 子部门ID列表)` |
|
||||
| `4` 仅本人 | `model.create_by == user.user_id` |
|
||||
| `5` 自定义 | 并集:(create_dept_id IN 自定义部门) OR (create_by IN 自定义用户) |
|
||||
|
||||
#### 多角色合并(并集语义)
|
||||
|
||||
用户可能有多个角色,每个角色有自己的 data_scope。合并策略:
|
||||
|
||||
1. 任一角色 data_scope=1(全部) → 不过滤
|
||||
2. 否则,收集所有角色的有效过滤范围,取并集
|
||||
3. 并集计算:将所有 scope 2/3 转为具体部门 ID 集合,scope 4 转为用户 ID 集合,scope 5 直接用自定义范围 → 全部合并后构建 `OR` 条件
|
||||
|
||||
#### 集成方式
|
||||
|
||||
在 `admin.py` 等路由的 list 接口中:
|
||||
|
||||
```python
|
||||
@router.get("/customers")
|
||||
async def list_customers(
|
||||
...,
|
||||
user: dict = Depends(require_menu_code("biz:customer:list")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
query = db.query(BizCustomer).filter(BizCustomer.del_flag == 0, ...)
|
||||
query = apply_data_scope(query, BizCustomer, user, db)
|
||||
...
|
||||
```
|
||||
|
||||
#### 依赖信息
|
||||
|
||||
需要 `user_info` dict 中包含 `dept_id`。当前 JWT payload 中 `region_id` 即为 dept_id(在 `public.py` 登录时已设置)。
|
||||
|
||||
---
|
||||
|
||||
## 模块 3:前端用户角色选择器
|
||||
|
||||
### 现状
|
||||
- 后端 `UserCreateBody` / `UserUpdateBody` 已支持 `roleIds` 字段
|
||||
- 后端 `GET /web/admin/roles/options` 已返回角色下拉列表
|
||||
- `web-admin/src/views/system/user/index.vue` 表单中**没有角色选择器**
|
||||
|
||||
### 修改方案
|
||||
|
||||
在 `user/index.vue` 的创建/编辑弹窗中:
|
||||
1. 添加角色多选下拉框(`el-select multiple`)
|
||||
2. 调用 `GET /web/admin/roles/options` 获取角色列表
|
||||
3. formData 中添加 `roleIds` 字段
|
||||
4. 提交时一并传给后端
|
||||
|
||||
**参考**:`modules/user-dialog.vue` 中已有类似实现,可参考其角色选择逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 模块 4:前端按钮级权限
|
||||
|
||||
### 现状
|
||||
- `v-auth` 指令已实现(读取 `route.meta.authList`)
|
||||
- `hasAuth()` composable 已实现
|
||||
- **但实际业务页面中几乎未使用**
|
||||
|
||||
### 修改方案
|
||||
|
||||
在每个业务页面的操作按钮上添加权限控制:
|
||||
|
||||
```vue
|
||||
<!-- 新增按钮 -->
|
||||
<el-button v-if="hasAuth('add')" type="primary" @click="handleAdd">新增</el-button>
|
||||
|
||||
<!-- 编辑按钮 -->
|
||||
<el-button v-if="hasAuth('edit')" link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<el-button v-if="hasAuth('delete')" link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
```
|
||||
|
||||
**权限标识来源**:`sys_menu` 中 `menu_type='F'` 的按钮权限记录的 `perms` 字段。
|
||||
|
||||
**注意**:当前 `v-auth` 指令从 `route.meta.authList` 读取,而 `hasAuth()` 从 `userStore.getUserInfo.buttons` 读取。需确保按钮权限列表在登录时正确加载到这两个位置。
|
||||
|
||||
### 涉及页面
|
||||
- `src/views/system/user/index.vue`
|
||||
- `src/views/system/role/index.vue`(角色管理本身也需按钮权限)
|
||||
- `src/views/basic-info/customer/index.vue`
|
||||
- `src/views/basic-info/cell-sample-type/index.vue`
|
||||
- `src/views/basic-info/collection-region/index.vue`
|
||||
- `src/views/basic-info/collection-protocol-no/index.vue`
|
||||
- `src/views/basic-info/sample-code/index.vue`
|
||||
- 其他业务页面
|
||||
|
||||
---
|
||||
|
||||
## 模块 5:admin 无权限码安全修复
|
||||
|
||||
### 现状
|
||||
|
||||
`backend/app/api/public.py` 中 `/web/auth/menu-codes` 端点:
|
||||
|
||||
```python
|
||||
if not codes:
|
||||
return {"isSuperAdmin": False, "all": True, "menuCodes": []} # ← 安全漏洞
|
||||
```
|
||||
|
||||
当 admin 用户没有任何菜单权限时,返回 `all: true`,前端将此视为拥有所有权限。
|
||||
|
||||
### 修改方案
|
||||
|
||||
**后端**:当 codes 为空时返回 `all: false`,不再赋予全部权限。
|
||||
|
||||
```python
|
||||
if not codes:
|
||||
return {"isSuperAdmin": False, "all": False, "menuCodes": []}
|
||||
```
|
||||
|
||||
**前端**:确保 `MenuProcessor.ts` 正确处理 `all: false` + 空 `menuCodes` 的情况——显示无菜单/无权限页面。
|
||||
|
||||
---
|
||||
|
||||
## 模块 6:多角色权限合并
|
||||
|
||||
### 后端菜单权限码
|
||||
|
||||
`iter_user_menu_perm_codes()` 已通过 `sys_user_role → sys_role_menu → sys_menu` 链路查询,天然支持多角色菜单并集(`DISTINCT` 去重)。**无需修改**。
|
||||
|
||||
### 后端数据权限
|
||||
|
||||
新增函数 `resolve_user_data_scope_union(db, user_id) -> DataScopeFilter`:
|
||||
|
||||
1. 查询用户所有有效角色的 `data_scope` 和 `scope_details`
|
||||
2. 合并策略:
|
||||
- 任一角色 scope=1 → 返回 "全部"(不过滤)
|
||||
- 收集所有角色的有效范围:
|
||||
- scope=2 → 加入 user.dept_id 到部门集合
|
||||
- scope=3 → 加入 user.dept_id + 所有子部门到部门集合
|
||||
- scope=4 → 加入 user.user_id 到用户集合
|
||||
- scope=5 → 加入 scope_details 中的 dept 和 user 到各自集合
|
||||
- 最终条件:`create_dept_id IN 部门集合 OR create_by IN 用户集合`
|
||||
3. 返回统一的过滤条件对象
|
||||
|
||||
---
|
||||
|
||||
## 不在本次修复范围
|
||||
|
||||
- 菜单祖先自动包含(P3,前端已通过 el-tree 不设 check-strictly 间接处理)
|
||||
- 工作流权限控制(独立模块,另行设计)
|
||||
- 小程序端权限(独立模块)
|
||||
|
||||
## 影响评估
|
||||
|
||||
- **后端**:新增 1 个文件(data_scope.py),修改 ~6 个路由文件
|
||||
- **前端**:修改 ~10 个页面文件,不新增文件
|
||||
- **数据库**:新增 sys_menu F 类型按钮权限记录的 SQL 脚本
|
||||
- **风险**:低。所有修改基于已有的基础设施,不改变数据模型
|
||||
@@ -0,0 +1,354 @@
|
||||
# 质量检测 — 第三方/实验室主数据、质检工单与质检任务
|
||||
|
||||
**日期**:2026-05-17
|
||||
**依据**:用户确认的业务规则;`docs/中科兴皖需求V0.3.md` 签收后触发样本检测流程;既有 `biz_sample_reception`、样本接收 Web 页。
|
||||
**范围**:`web-admin` + `backend`(FastAPI);租户隔离、软删除、公共字段与工程规范一致。
|
||||
|
||||
---
|
||||
|
||||
## 1. 已确认业务规则
|
||||
|
||||
| 项 | 规则 |
|
||||
|----|------|
|
||||
| 样本类型 | **不考虑**;主数据维护全部检测项目,由人员自选 |
|
||||
| 样本接收 UI | **保持现状**,不在接收弹窗勾选检测项目 |
|
||||
| 触发时机 | 接收结论为 **接收(`js`)** 时,自动生成 **1 条质检工单**(与 `biz_sample_reception` 1:1);**拒收(`bj`)** 不建单 |
|
||||
| 项目配置 | 在 **质检工单** 界面配置:检测方式(实验室自检 / 第三方检测,可多选)及具体项目 |
|
||||
| 任务生成 | **实验室 + 第三方** 所选项目 **各生成 1 条质检任务**;处理逻辑按任务类型区分 |
|
||||
| 任务差异 | 实验室自检:本实验室处理(一期仅状态/骨架);第三方:需 **上传第三方检测材料** |
|
||||
| 样本类型映射 | **不做** |
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标(一期)
|
||||
|
||||
- 在「质量检测」下新增 **5 个菜单**(见 §4)。
|
||||
- 主数据 CRUD:第三方检测公司、第三方检测项目及费用(归属公司)、实验室自检项目。
|
||||
- 接收成功 → 自动创建 **待配置** 质检工单。
|
||||
- 质检工单:列表、查看、**配置检测**(勾选方式与项目)→ 确认后批量生成任务,工单变为 **已配置**。
|
||||
- 质检任务:列表、查看;实验室任务最小状态流转;第三方任务支持附件上传并完结。
|
||||
- 后端在 `create_sample_reception` 提交事务内(或同事务)创建工单,保证幂等(同一 `reception_id` 仅一条工单)。
|
||||
|
||||
### 2.2 非目标(一期不做)
|
||||
|
||||
- 检测任务完整实验过程录入、仪器对接、审批流。
|
||||
- 检测结论回写协议样本状态(`sample_status` / 作废 / 入库)— 留二期。
|
||||
- 按样本类型自动匹配项目。
|
||||
- 在接收弹窗配置检测项目。
|
||||
- 工作流引擎驱动质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. 方案对比(已选方案 A)
|
||||
|
||||
| 方案 | 说明 | 结论 |
|
||||
|------|------|------|
|
||||
| **A. 工单头 + 明细行 + 任务表** | 工单存头信息;配置时写入工单明细(快照);确认后按明细生成 `biz_qc_task` | **采用** — 便于待配置态展示、费用汇总、审计快照 |
|
||||
| B. 仅工单 + 任务 | 配置直接写任务,无中间明细 | 待配置编辑与历史追溯弱 |
|
||||
| C. JSON 存配置 | 配置存 JSON 字段 | 不利于列表筛选与报表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 菜单与路由
|
||||
|
||||
一级菜单 **质量检测**(`/quality-inspection`,已有)下新增:
|
||||
|
||||
| 序号 | 菜单名称 | path | 组件 | 权限前缀(建议) |
|
||||
|------|----------|------|------|------------------|
|
||||
| 1 | 第三方检测公司 | `third-party-company` | `views/quality-inspection/third-party-company/index.vue` | `biz:qcThirdPartyCompany` |
|
||||
| 2 | 第三方检测项目 | `third-party-item` | `views/quality-inspection/third-party-item/index.vue` | `biz:qcThirdPartyItem` |
|
||||
| 3 | 实验室自检项目 | `lab-test-item` | `views/quality-inspection/lab-test-item/index.vue` | `biz:qcLabTestItem` |
|
||||
| 4 | 质检工单 | `qc-work-order` | `views/quality-inspection/qc-work-order/index.vue` | `biz:qcWorkOrder` |
|
||||
| 5 | 质检任务 | `qc-task` | `views/quality-inspection/qc-task/index.vue` | `biz:qcTask` |
|
||||
|
||||
保留现有:**样本接收**、**接收检查项配置**。
|
||||
|
||||
同步:`web-admin/src/router/modules/sqxp.ts`、`backend/app/api/menu.py`、`docs/sql/sys_menu_quality_inspection.sql`(或增量 SQL)。
|
||||
|
||||
UI:列表对齐 `system/unit/index.vue`;弹窗 `label-position="top"`、`.dialog-form-layout`;色板 Fresh Mint。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
命名空间表前缀:`biz_qc_*`。均含标准公共字段(`id` UUID、`tenant_no`、`order_num`、`remark`、`create_by`、`create_dept_id`、`create_time`、`update_by`、`update_time`、`del_flag`)。
|
||||
|
||||
### 5.1 `biz_qc_third_party_company` — 第三方检测公司
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| company_code | VARCHAR(64) | 公司编码,租户内唯一 |
|
||||
| company_name | VARCHAR(200) | 公司名称 |
|
||||
| contact_name | VARCHAR(100) | 联系人 |
|
||||
| contact_phone | VARCHAR(32) | 联系电话 |
|
||||
| address | VARCHAR(500) | 地址 |
|
||||
| status | CHAR(1) | `1` 启用 / `0` 停用 |
|
||||
|
||||
### 5.2 `biz_qc_third_party_item` — 第三方检测项目及费用
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| company_id | VARCHAR(64) | FK → 第三方公司 |
|
||||
| item_code | VARCHAR(64) | 项目编码(租户内可与 company 联合唯一) |
|
||||
| item_name | VARCHAR(200) | 项目名称 |
|
||||
| fee_amount | DECIMAL(18,2) | 费用(元) |
|
||||
| status | CHAR(1) | `1` 启用 / `0` 停用 |
|
||||
|
||||
列表页支持按 **公司** 筛选;新增/编辑必须选择所属公司。
|
||||
|
||||
### 5.3 `biz_qc_lab_test_item` — 实验室自检项目
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| item_code | VARCHAR(64) | 项目编码,租户内唯一 |
|
||||
| item_name | VARCHAR(200) | 项目名称 |
|
||||
| description | VARCHAR(500) | 描述/方法说明(可选) |
|
||||
| status | CHAR(1) | `1` 启用 / `0` 停用 |
|
||||
|
||||
### 5.4 `biz_qc_work_order` — 质检工单
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| work_order_no | VARCHAR(32) | 工单号(规则:`ZJ` + yyyyMMdd + 4 位序,租户内按日序) |
|
||||
| reception_id | VARCHAR(64) | 接收记录 ID,**唯一**(未删除) |
|
||||
| protocol_sample_id | VARCHAR(64) | 协议样本 ID |
|
||||
| collection_protocol_id | VARCHAR(64) | 协议单 ID |
|
||||
| protocol_no | VARCHAR(64) | 协议号快照(列表展示) |
|
||||
| sample_code | VARCHAR(32) | 样本编码快照 |
|
||||
| work_order_status | VARCHAR(16) | 见 §6.1 |
|
||||
| has_lab | CHAR(1) | `1`/`0` 是否含实验室自检 |
|
||||
| has_third_party | CHAR(1) | `1`/`0` 是否含第三方检测 |
|
||||
| configured_time | DATETIME | 配置确认时间 |
|
||||
| configured_by | VARCHAR(64) | 配置人用户 ID |
|
||||
| third_party_fee_total | DECIMAL(18,2) | 第三方项目费用合计(配置时汇总) |
|
||||
|
||||
### 5.5 `biz_qc_work_order_lab_item` — 工单-实验室项目明细(配置快照)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| work_order_id | VARCHAR(64) | 工单 ID |
|
||||
| lab_test_item_id | VARCHAR(64) | 主数据 ID |
|
||||
| item_code / item_name | VARCHAR | 快照 |
|
||||
|
||||
### 5.6 `biz_qc_work_order_third_item` — 工单-第三方项目明细(配置快照)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| work_order_id | VARCHAR(64) | 工单 ID |
|
||||
| third_party_item_id | VARCHAR(64) | 主数据 ID |
|
||||
| company_id | VARCHAR(64) | 公司 ID |
|
||||
| company_name | VARCHAR(200) | 公司名快照 |
|
||||
| item_code / item_name | VARCHAR | 快照 |
|
||||
| fee_amount | DECIMAL(18,2) | 费用快照 |
|
||||
|
||||
### 5.7 `biz_qc_task` — 质检任务
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| work_order_id | VARCHAR(64) | 工单 ID |
|
||||
| task_no | VARCHAR(32) | 任务号(`RW` + 日序,或工单号 + `-` + 序号) |
|
||||
| task_type | VARCHAR(16) | `sysz` 实验室自检 / `dsfjc` 第三方检测 |
|
||||
| source_line_id | VARCHAR(64) | 对应工单明细行 ID(lab 或 third 明细表) |
|
||||
| item_code / item_name | VARCHAR | 快照 |
|
||||
| company_id / company_name | VARCHAR | 第三方任务填公司快照;实验室为空 |
|
||||
| fee_amount | DECIMAL(18,2) | 第三方费用快照 |
|
||||
| task_status | VARCHAR(16) | 见 §6.2 |
|
||||
| third_party_attachments | VARCHAR(4000) | 第三方检测材料 URL,逗号分隔(MinIO) |
|
||||
| completed_time | DATETIME | 完成时间 |
|
||||
| completed_by | VARCHAR(64) | 完成人 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态与字典
|
||||
|
||||
### 6.1 工单状态 `work_order_status`
|
||||
|
||||
| 值 | 标签 | 说明 |
|
||||
|----|------|------|
|
||||
| `dpz` | 待配置 | 接收后自动创建 |
|
||||
| `ypz` | 已配置 | 已确认检测方式与项目,任务已生成 |
|
||||
| `ywc` | 已完成 | 全部任务已完成(可由任务聚合更新,一期可实现自动推进) |
|
||||
|
||||
### 6.2 任务状态 `task_status`
|
||||
|
||||
| 值 | 标签 | 说明 |
|
||||
|----|------|------|
|
||||
| `dzx` | 待执行 | 刚生成 |
|
||||
| `jxz` | 进行中 | 实验室:开始处理;第三方:已上传部分材料可选 |
|
||||
| `ywc` | 已完成 | 实验室:手动完成;第三方:上传材料后确认完成 |
|
||||
|
||||
### 6.3 字典类型(可选 `sys_dict`)
|
||||
|
||||
- `qc_task_type`:`sysz` / `dsfjc`
|
||||
- `qc_work_order_status`:`dpz` / `ypz` / `ywc`
|
||||
- `qc_task_status`:`dzx` / `jxz` / `ywc`
|
||||
|
||||
一期也可前端常量 + 标签函数(与样本接收结论一致)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 核心流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant SR as 样本接收
|
||||
participant WO as 质检工单
|
||||
participant TK as 质检任务
|
||||
|
||||
SR->>WO: 结论=接收(js) 提交
|
||||
Note over WO: 创建工单 status=dpz
|
||||
WO->>WO: 质检专员打开「配置检测」
|
||||
Note over WO: 勾选 has_lab / has_third_party<br/>多选启用项目
|
||||
WO->>WO: POST 确认配置
|
||||
WO->>WO: 写入明细快照 status=ypz
|
||||
loop 每个实验室项目
|
||||
WO->>TK: 创建 task_type=sysz
|
||||
end
|
||||
loop 每个第三方项目
|
||||
WO->>TK: 创建 task_type=dsfjc
|
||||
end
|
||||
TK->>TK: 实验室:状态流转(一期骨架)
|
||||
TK->>TK: 第三方:上传材料 → 完成
|
||||
TK->>WO: 全部 ywc 时工单→ywc
|
||||
```
|
||||
|
||||
### 7.1 自动建单(后端)
|
||||
|
||||
在 `create_sample_reception` 中,当 `accept_conclusion == 'js'` 且提交成功后:
|
||||
|
||||
1. 若该 `reception_id` 尚无未删除工单,则插入 `biz_qc_work_order`(`dpz`)。
|
||||
2. 填充 `reception_id`、`protocol_sample_id`、`collection_protocol_id`;从协议单/样本补 `protocol_no`、`sample_code`。
|
||||
3. 生成 `work_order_no`。
|
||||
4. **同一事务** commit。
|
||||
|
||||
幂等:已存在工单则跳过(防重复接收场景应在前置校验拦截,此处防御性检查)。
|
||||
|
||||
### 7.2 配置检测(工单)
|
||||
|
||||
- 仅 `work_order_status = dpz` 可配置。
|
||||
- 请求体示例:
|
||||
- `hasLab`: boolean
|
||||
- `hasThirdParty`: boolean
|
||||
- `labItemIds`: string[](`hasLab` 时至少 1 项)
|
||||
- `thirdPartyItemIds`: string[](`hasThirdParty` 时至少 1 项)
|
||||
- 校验:所选 ID 均为本租户 **启用** 主数据;第三方项目带公司信息写入快照。
|
||||
- 行为:
|
||||
1. 删除该工单下已有明细(`dpz` 态理论上无任务,若有则拒绝)。
|
||||
2. 批量插入 lab/third 明细行。
|
||||
3. 按明细 **每条生成 1 个任务**(`dzx`)。
|
||||
4. 更新工单:`ypz`、`has_lab`、`has_third_party`、`third_party_fee_total`、`configured_*`。
|
||||
- **已配置后一期不允许修改项目**(避免任务与明细不一致);追加以二期为准。
|
||||
|
||||
### 7.3 任务处理(一期)
|
||||
|
||||
| 类型 | 操作 |
|
||||
|------|------|
|
||||
| `sysz` | 列表查看;支持「开始」「完成」状态变更(无结果表单) |
|
||||
| `dsfjc` | 上传第三方检测材料(附件卡片,MinIO);「完成」前至少 1 个附件 |
|
||||
|
||||
工单完成:该工单下所有任务 `ywc` → 工单 `ywc`(可在任务完成 API 内聚合更新)。
|
||||
|
||||
---
|
||||
|
||||
## 8. API 设计(前缀 `/api/web/admin`)
|
||||
|
||||
### 8.1 第三方检测公司
|
||||
|
||||
- `GET /qc-third-party-companies` — 分页列表,`keyword`、`status`
|
||||
- `GET /qc-third-party-companies/options` — 下拉(启用)
|
||||
- `POST /qc-third-party-companies`
|
||||
- `PUT /qc-third-party-companies/{id}`
|
||||
- `DELETE /qc-third-party-companies/{id}` — 软删;若存在未删第三方项目则拒绝
|
||||
|
||||
### 8.2 第三方检测项目
|
||||
|
||||
- `GET /qc-third-party-items` — `companyId`、`keyword`、`status`
|
||||
- `GET /qc-third-party-items/options` — `companyId` 可选
|
||||
- `POST` / `PUT` / `DELETE` — 标准 CRUD
|
||||
|
||||
### 8.3 实验室自检项目
|
||||
|
||||
- `GET /qc-lab-test-items` — 分页
|
||||
- `GET /qc-lab-test-items/options` — 启用项,供工单配置多选
|
||||
- `POST` / `PUT` / `DELETE`
|
||||
|
||||
### 8.4 质检工单
|
||||
|
||||
- `GET /qc-work-orders` — `keyword`(工单号/协议号/样本编码)、`workOrderStatus`
|
||||
- `GET /qc-work-orders/{id}` — 含接收摘要、明细、任务列表
|
||||
- `POST /qc-work-orders/{id}/configure` — §7.2 配置确认
|
||||
|
||||
(不提供手工新建:仅接收触发。)
|
||||
|
||||
### 8.5 质检任务
|
||||
|
||||
- `GET /qc-tasks` — `keyword`、`taskType`、`taskStatus`、`workOrderId`
|
||||
- `GET /qc-tasks/{id}`
|
||||
- `POST /qc-tasks/{id}/start` — `sysz`:`dzx`→`jxz`
|
||||
- `POST /qc-tasks/{id}/complete` — `sysz`:`jxz`→`ywc`;`dsfjc`:校验附件后 `ywc`
|
||||
- `PUT /qc-tasks/{id}/attachments` — `dsfjc` 更新 `third_party_attachments`
|
||||
|
||||
路由注册:`app/api/qc_*.py` 或合并 `quality_inspection.py`;`__init__.py` + `main.py`。
|
||||
|
||||
权限:`require_menu_code` + `apply_data_scope`(与 `sample_reception` 一致)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 前端要点
|
||||
|
||||
### 9.1 第三方检测公司 / 项目 / 实验室项目
|
||||
|
||||
- 标准列表 CRUD;项目列表带公司筛选,表单公司用 `el-select`(options 接口)。
|
||||
- 停用主数据后,工单配置下拉不出现,**不影响**已生成工单快照与任务。
|
||||
|
||||
### 9.2 质检工单
|
||||
|
||||
- 列表列:工单号、协议号、样本编码、接收时间、工单状态、检测方式(实验室/第三方标签)、配置时间。
|
||||
- 操作:**查看**、**配置检测**(仅 `dpz`)。
|
||||
- 配置弹窗:
|
||||
- 区块1:检测方式 `el-checkbox-group`(实验室自检、第三方检测)
|
||||
- 区块2:实验室项目 `el-checkbox-group` 或表格多选(仅启用项)
|
||||
- 区块3:第三方项目 — 可按公司分组展示全部启用项目 + 费用列,多选
|
||||
- 确认前校验:勾选方式与所选项目一致;至少选 1 个项目
|
||||
- 查看弹窗:只读展示接收信息、已选明细、关联任务列表。
|
||||
|
||||
### 9.3 质检任务
|
||||
|
||||
- 列表:`taskType` 筛选;类型列区分标签。
|
||||
- 操作:
|
||||
- 实验室:开始 / 完成
|
||||
- 第三方:上传材料(附件卡片)、完成
|
||||
- 查看:展示工单关联、项目快照、附件只读。
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施分期建议
|
||||
|
||||
| 阶段 | 交付 |
|
||||
|------|------|
|
||||
| **P1** | SQL + Models + 主数据 3 模块 API/页面 |
|
||||
| **P2** | 接收触发改单 + 工单列表/配置/查看 + 配置生成任务 |
|
||||
| **P3** | 任务列表 + 分类型操作 + 工单完成聚合 |
|
||||
| **P4(二期)** | 检测结果、样本状态联动、配置后追加项目、统计报表 |
|
||||
|
||||
实施计划文件:`docs/superpowers/plans/2026-05-17-quality-inspection-modules.md`(待 `writing-plans` 生成)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 测试要点
|
||||
|
||||
- 接收 `js` → 生成 `dpz` 工单;`bj` → 无工单。
|
||||
- 同一接收重复提交应被接收层拦截,不重复工单。
|
||||
- 配置仅选实验室 → 仅 `sysz` 任务;仅第三方 → 仅 `dsfjc` 任务;两者都选 → 两类任务总数 = 所选项目数。
|
||||
- 第三方任务无附件不可完成;全部任务完成后工单 `ywc`。
|
||||
- 租户隔离、软删除、停用主数据不参与新配置。
|
||||
|
||||
---
|
||||
|
||||
## 12. 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-05-17 | 初稿:用户确认人工选配、接收触发、工单配置、双类型任务 |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,948 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>方案B:顶部摘要卡+折叠面板 — 采集协议单查看</title>
|
||||
<style>
|
||||
/* ═══════════════════════════════════════════
|
||||
Reset & Base
|
||||
═══════════════════════════════════════════ */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: #1e293b;
|
||||
background: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Modal Overlay
|
||||
═══════════════════════════════════════════ */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Dialog Shell
|
||||
═══════════════════════════════════════════ */
|
||||
.dialog {
|
||||
width: 1200px;
|
||||
max-width: 96vw;
|
||||
max-height: calc(100vh - 48px);
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 60px rgba(0,0,0,.15), 0 0 0 1px rgba(16,185,129,.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: dialogIn .28s ease-out;
|
||||
}
|
||||
@keyframes dialogIn {
|
||||
from { opacity: 0; transform: translateY(24px) scale(.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Dialog Header
|
||||
═══════════════════════════════════════════ */
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 28px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dialog-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.dialog-header h3::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: #10b981;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.dialog-header .subtitle {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.close-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all .2s;
|
||||
color: #64748b;
|
||||
font-size: 20px;
|
||||
}
|
||||
.close-btn:hover { background: #fee2e2; color: #ef4444; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Dialog Body (scrollable)
|
||||
═══════════════════════════════════════════ */
|
||||
.dialog-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
.dialog-body::-webkit-scrollbar { width: 6px; }
|
||||
.dialog-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.dialog-body::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Top Summary Strip (Dashboard Card)
|
||||
═══════════════════════════════════════════ */
|
||||
.summary-strip {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #f0fdf4 50%, #ecfdf5 100%);
|
||||
border: 1px solid #d1fae5;
|
||||
border-radius: 14px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.summary-strip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
right: -30px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: radial-gradient(circle, rgba(16,185,129,.08) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.summary-strip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 40%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: radial-gradient(circle, rgba(16,185,129,.05) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.summary-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
transition: all .2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.summary-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(16,185,129,.1);
|
||||
border-color: #a7f3d0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.summary-card .card-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.summary-card .card-icon.green { background: #d1fae5; color: #059669; }
|
||||
.summary-card .card-icon.blue { background: #dbeafe; color: #2563eb; }
|
||||
.summary-card .card-icon.amber { background: #fef3c7; color: #d97706; }
|
||||
.summary-card .card-icon.violet { background: #ede9fe; color: #7c3aed; }
|
||||
.summary-card .card-icon.rose { background: #ffe4e6; color: #e11d48; }
|
||||
.summary-card .card-icon.cyan { background: #cffafe; color: #0891b2; }
|
||||
.summary-card .card-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.summary-card .card-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.summary-card .card-value.mono { font-family: "SF Mono", "Cascadia Code", monospace; font-size: 15px; }
|
||||
|
||||
/* status badge inside summary */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.badge-green { background: #d1fae5; color: #059669; }
|
||||
.badge-amber { background: #fef3c7; color: #b45309; }
|
||||
.badge-gray { background: #f1f5f9; color: #64748b; }
|
||||
.badge-blue { background: #dbeafe; color: #2563eb; }
|
||||
.badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Collapsible Panels (details/summary)
|
||||
═══════════════════════════════════════════ */
|
||||
.panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition: box-shadow .25s;
|
||||
}
|
||||
.panel:hover { box-shadow: 0 2px 8px rgba(0,0,0,.04); }
|
||||
|
||||
/* Hide default marker */
|
||||
.panel summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.panel summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: all .2s;
|
||||
}
|
||||
.panel[open] .panel-header {
|
||||
border-bottom-color: #e2e8f0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.panel-header .panel-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-header .panel-icon.ic-green { background: #ecfdf5; color: #10b981; }
|
||||
.panel-header .panel-icon.ic-blue { background: #eff6ff; color: #3b82f6; }
|
||||
.panel-header .panel-icon.ic-amber { background: #fffbeb; color: #f59e0b; }
|
||||
.panel-header .panel-icon.ic-violet { background: #f5f3ff; color: #8b5cf6; }
|
||||
.panel-header .panel-icon.ic-rose { background: #fff1f2; color: #f43f5e; }
|
||||
.panel-header .panel-icon.ic-cyan { background: #ecfeff; color: #06b6d4; }
|
||||
|
||||
.panel-header .panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panel-header .panel-count {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.panel-header .chevron {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
transition: transform .3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel[open] .panel-header .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 20px 24px;
|
||||
animation: panelFadeIn .25s ease-out;
|
||||
}
|
||||
@keyframes panelFadeIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Key-Value Grid
|
||||
═══════════════════════════════════════════ */
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
.kv-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
border-right: 1px solid #f1f5f9;
|
||||
min-height: 56px;
|
||||
justify-content: center;
|
||||
}
|
||||
.kv-item:nth-child(3n) { border-right: none; }
|
||||
.kv-item .kv-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.kv-item .kv-value {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv-item .kv-value.muted { color: #64748b; font-weight: 400; }
|
||||
|
||||
/* Sub-section title */
|
||||
.sub-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
padding: 14px 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sub-title::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #10b981;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.sub-title:first-child { padding-top: 0; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Attachment Thumbnails
|
||||
═══════════════════════════════════════════ */
|
||||
.attach-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.attach-thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
position: relative;
|
||||
}
|
||||
.attach-thumb:hover {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 2px 8px rgba(16,185,129,.15);
|
||||
}
|
||||
.attach-thumb svg { width: 24px; height: 24px; stroke: #94a3b8; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Data Table
|
||||
═══════════════════════════════════════════ */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.data-table thead th {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table tbody td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #1e293b;
|
||||
}
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
.data-table tbody tr:hover { background: #f8fafc; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Payment Cards
|
||||
═══════════════════════════════════════════ */
|
||||
.payment-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.payment-card .pay-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.payment-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.payment-row .pay-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.payment-row .pay-item .pay-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.payment-row .pay-item .pay-val {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Flow timeline */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding: 8px 0 8px 16px;
|
||||
}
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 14px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #10b981;
|
||||
}
|
||||
.timeline-item.done::before { background: #10b981; }
|
||||
.timeline-item .tl-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.timeline-item .tl-content {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.timeline-item .tl-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Detail mini cards */
|
||||
.detail-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.detail-card .dc-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.detail-card .dc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.detail-card .dc-row .dc-label { color: #94a3b8; }
|
||||
.detail-card .dc-row .dc-val { color: #1e293b; font-weight: 500; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Dialog Footer
|
||||
═══════════════════════════════════════════ */
|
||||
.dialog-footer {
|
||||
padding: 16px 28px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-close {
|
||||
padding: 9px 28px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-close:hover {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Money highlight
|
||||
═══════════════════════════════════════════ */
|
||||
.money { color: #059669; font-weight: 600; font-family: "SF Mono", "Cascadia Code", monospace; }
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Responsive
|
||||
═══════════════════════════════════════════ */
|
||||
@media (max-width: 900px) {
|
||||
.summary-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.kv-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.kv-item:nth-child(3n) { border-right: 1px solid #f1f5f9; }
|
||||
.kv-item:nth-child(2n) { border-right: none; }
|
||||
.detail-cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Modal Overlay -->
|
||||
<div class="overlay">
|
||||
<div class="dialog">
|
||||
|
||||
<!-- ─── Header ─── -->
|
||||
<div class="dialog-header">
|
||||
<h3>查看协议单 <span class="subtitle">P2025-0501-001</span></h3>
|
||||
<button class="close-btn" title="关闭">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── Body ─── -->
|
||||
<div class="dialog-body">
|
||||
|
||||
<!-- ═══════════ Top Summary Strip ═══════════ -->
|
||||
<div class="summary-strip">
|
||||
<div class="summary-grid">
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon green">📄</div>
|
||||
<div class="card-label">协议号</div>
|
||||
<div class="card-value mono">P2025-0501-001</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon green">✅</div>
|
||||
<div class="card-label">协议状态</div>
|
||||
<div class="card-value"><span class="badge badge-green">生效中</span></div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon violet">👩</div>
|
||||
<div class="card-label">母亲姓名</div>
|
||||
<div class="card-value">王美丽</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon blue">📅</div>
|
||||
<div class="card-label">登记日期</div>
|
||||
<div class="card-value">2025-05-10</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon cyan">🏥</div>
|
||||
<div class="card-label">采集医院</div>
|
||||
<div class="card-value" style="font-size:14px">合肥市第一人民医院</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="card-icon amber">💰</div>
|
||||
<div class="card-label">合同金额</div>
|
||||
<div class="card-value money">¥50,000</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Panel 1: 基本信息 (默认展开) ═══════════ -->
|
||||
<details class="panel" open>
|
||||
<summary>
|
||||
<div class="panel-header">
|
||||
<div class="panel-icon ic-green">📋</div>
|
||||
<span class="panel-title">基本信息</span>
|
||||
<span class="panel-count">8项</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="kv-label">协议号</span>
|
||||
<span class="kv-value" style="font-family:monospace">P2025-0501-001</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="kv-label">协议状态</span>
|
||||
<span class="kv-value"><span class="badge badge-green">生效中</span></span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="kv-label">登记日期</span>
|
||||
<span class="kv-value">2025-05-10</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="kv-label">采集专员</span>
|
||||
<span class="kv-value">李明</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="kv-label">创建时间</span>
|
||||
<span class="kv-value muted">2025-05-10 14:30:22</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="kv-label">更新时间</span>
|
||||
<span class="kv-value muted">2025-05-10 16:45:10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 14px 16px; border-bottom: 1px solid #f1f5f9;">
|
||||
<div style="display:flex; gap:24px;">
|
||||
<div style="flex:1">
|
||||
<div class="kv-label" style="margin-bottom:8px">协议附件</div>
|
||||
<div class="attach-row">
|
||||
<div class="attach-thumb" title="采集协议书.pdf">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>
|
||||
</div>
|
||||
<div class="attach-thumb" title="知情同意书.pdf">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>
|
||||
</div>
|
||||
<div class="attach-thumb" title="身份证明.jpg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div class="kv-label" style="margin-bottom:8px">备注</div>
|
||||
<div class="kv-value muted">客户要求加急处理,需优先安排采集和检测</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ═══════════ Panel 2: 客户信息 ═══════════ -->
|
||||
<details class="panel">
|
||||
<summary>
|
||||
<div class="panel-header">
|
||||
<div class="panel-icon ic-violet">👥</div>
|
||||
<span class="panel-title">客户信息</span>
|
||||
<span class="panel-count">19项</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
|
||||
<div class="sub-title">母亲信息</div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item"><span class="kv-label">姓名</span><span class="kv-value">王美丽</span></div>
|
||||
<div class="kv-item"><span class="kv-label">电话</span><span class="kv-value">138****5678</span></div>
|
||||
<div class="kv-item"><span class="kv-label">身份证号</span><span class="kv-value">3401****1234</span></div>
|
||||
<div class="kv-item"><span class="kv-label">出生年月</span><span class="kv-value">1992-06</span></div>
|
||||
<div class="kv-item"><span class="kv-label">民族</span><span class="kv-value">汉族</span></div>
|
||||
<div class="kv-item"><span class="kv-label">住址</span><span class="kv-value muted">安徽省合肥市蜀山区望江西路128号</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sub-title">父亲信息</div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item"><span class="kv-label">姓名</span><span class="kv-value">张建国</span></div>
|
||||
<div class="kv-item"><span class="kv-label">电话</span><span class="kv-value">139****8901</span></div>
|
||||
<div class="kv-item"><span class="kv-label">身份证号</span><span class="kv-value">3401****5678</span></div>
|
||||
</div>
|
||||
|
||||
<div class="sub-title">新生儿信息</div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item"><span class="kv-label">姓名</span><span class="kv-value">张小明</span></div>
|
||||
<div class="kv-item"><span class="kv-label">分娩时间</span><span class="kv-value">2025-05-08 06:30</span></div>
|
||||
<div class="kv-item"><span class="kv-label">性别</span><span class="kv-value">男</span></div>
|
||||
<div class="kv-item"><span class="kv-label">分娩方式</span><span class="kv-value">剖宫产</span></div>
|
||||
<div class="kv-item"><span class="kv-label">胎次</span><span class="kv-value">1</span></div>
|
||||
<div class="kv-item"><span class="kv-label">体重</span><span class="kv-value">3250g</span></div>
|
||||
<div class="kv-item"><span class="kv-label">Apgar评分 1min</span><span class="kv-value">9</span></div>
|
||||
<div class="kv-item"><span class="kv-label">Apgar评分 5min</span><span class="kv-value">10</span></div>
|
||||
<div class="kv-item"><span class="kv-label">Apgar评分 10min</span><span class="kv-value">10</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ═══════════ Panel 3: 采集信息 ═══════════ -->
|
||||
<details class="panel">
|
||||
<summary>
|
||||
<div class="panel-header">
|
||||
<div class="panel-icon ic-cyan">🧪</div>
|
||||
<span class="panel-title">采集信息</span>
|
||||
<span class="panel-count">6项</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item"><span class="kv-label">采集地区</span><span class="kv-value">华东地区</span></div>
|
||||
<div class="kv-item"><span class="kv-label">采集医院</span><span class="kv-value">合肥市第一人民医院</span></div>
|
||||
<div class="kv-item"><span class="kv-label">采集医护</span><span class="kv-value">陈护士</span></div>
|
||||
<div class="kv-item"><span class="kv-label">销售人员</span><span class="kv-value">赵经理</span></div>
|
||||
<div class="kv-item"><span class="kv-label">样本采集时间</span><span class="kv-value muted">2025-05-08 07:15</span></div>
|
||||
<div class="kv-item"><span class="kv-label">母血采集时间</span><span class="kv-value muted">2025-05-08 07:20</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ═══════════ Panel 4: 样本及运输信息 ═══════════ -->
|
||||
<details class="panel">
|
||||
<summary>
|
||||
<div class="panel-header">
|
||||
<div class="panel-icon ic-blue">📦</div>
|
||||
<span class="panel-title">样本及运输信息</span>
|
||||
<span class="panel-count">2条</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>样本编号</th>
|
||||
<th>样本类型</th>
|
||||
<th>样本状态</th>
|
||||
<th>运输方式</th>
|
||||
<th>快递单号</th>
|
||||
<th>发出时间</th>
|
||||
<th>接收时间</th>
|
||||
<th>存储温度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family:monospace">S20250508-001</td>
|
||||
<td>脐带血</td>
|
||||
<td><span class="badge badge-green">已入库</span></td>
|
||||
<td>顺丰冷链</td>
|
||||
<td style="font-family:monospace">SF1234567890</td>
|
||||
<td>2025-05-08 09:30</td>
|
||||
<td>2025-05-09 10:15</td>
|
||||
<td>-196℃ 液氮</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:monospace">S20250508-002</td>
|
||||
<td>母血</td>
|
||||
<td><span class="badge badge-green">已入库</span></td>
|
||||
<td>顺丰冷链</td>
|
||||
<td style="font-family:monospace">SF1234567891</td>
|
||||
<td>2025-05-08 09:30</td>
|
||||
<td>2025-05-09 10:15</td>
|
||||
<td>-20℃ 冰箱</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ═══════════ Panel 5: 缴费信息 ═══════════ -->
|
||||
<details class="panel">
|
||||
<summary>
|
||||
<div class="panel-header">
|
||||
<div class="panel-icon ic-amber">💳</div>
|
||||
<span class="panel-title">缴费信息</span>
|
||||
<span class="panel-count">3笔</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
|
||||
<!-- Payment summary -->
|
||||
<div class="payment-card">
|
||||
<div class="pay-title">缴费概览</div>
|
||||
<div class="payment-row">
|
||||
<div class="pay-item">
|
||||
<span class="pay-label">合同金额</span>
|
||||
<span class="pay-val money">¥50,000.00</span>
|
||||
</div>
|
||||
<div class="pay-item">
|
||||
<span class="pay-label">已缴金额</span>
|
||||
<span class="pay-val money">¥30,000.00</span>
|
||||
</div>
|
||||
<div class="pay-item">
|
||||
<span class="pay-label">待缴金额</span>
|
||||
<span class="pay-val" style="color:#f59e0b;font-weight:600">¥20,000.00</span>
|
||||
</div>
|
||||
<div class="pay-item">
|
||||
<span class="pay-label">缴费状态</span>
|
||||
<span class="pay-val"><span class="badge badge-amber">部分缴费</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail cards -->
|
||||
<div class="detail-cards">
|
||||
<div class="detail-card">
|
||||
<div class="dc-title">第1期 · <span class="badge badge-green" style="font-size:11px">已缴</span></div>
|
||||
<div class="dc-row"><span class="dc-label">缴费金额</span><span class="dc-val money">¥20,000.00</span></div>
|
||||
<div class="dc-row"><span class="dc-label">缴费方式</span><span class="dc-val">银行转账</span></div>
|
||||
<div class="dc-row"><span class="dc-label">缴费日期</span><span class="dc-val">2025-05-10</span></div>
|
||||
<div class="dc-row"><span class="dc-label">经办人</span><span class="dc-val">李明</span></div>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<div class="dc-title">第2期 · <span class="badge badge-green" style="font-size:11px">已缴</span></div>
|
||||
<div class="dc-row"><span class="dc-label">缴费金额</span><span class="dc-val money">¥10,000.00</span></div>
|
||||
<div class="dc-row"><span class="dc-label">缴费方式</span><span class="dc-val">微信支付</span></div>
|
||||
<div class="dc-row"><span class="dc-label">缴费日期</span><span class="dc-val">2025-05-12</span></div>
|
||||
<div class="dc-row"><span class="dc-label">经办人</span><span class="dc-val">李明</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment flow timeline -->
|
||||
<div style="margin-top:16px">
|
||||
<div class="sub-title" style="margin-bottom:12px">缴费进度</div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item done">
|
||||
<div class="tl-time">2025-05-10 14:30</div>
|
||||
<div class="tl-content">第1期缴费到账 <strong>¥20,000</strong></div>
|
||||
</div>
|
||||
<div class="timeline-item done">
|
||||
<div class="tl-time">2025-05-12 10:20</div>
|
||||
<div class="tl-content">第2期缴费到账 <strong>¥10,000</strong></div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<div class="tl-time">预计 2025-06-10</div>
|
||||
<div class="tl-content">第3期待缴 <strong>¥20,000</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ═══════════ Panel 6: 审核记录 ═══════════ -->
|
||||
<details class="panel">
|
||||
<summary>
|
||||
<div class="panel-header">
|
||||
<div class="panel-icon ic-rose">📝</div>
|
||||
<span class="panel-title">审核记录</span>
|
||||
<span class="panel-count">3条</span>
|
||||
<span class="chevron">▶</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="panel-body">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>审核节点</th>
|
||||
<th>审核人</th>
|
||||
<th>审核结果</th>
|
||||
<th>审核意见</th>
|
||||
<th>审核时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>协议登记审核</td>
|
||||
<td>张主管</td>
|
||||
<td><span class="badge badge-green">通过</span></td>
|
||||
<td style="color:#64748b">资料齐全,予以通过</td>
|
||||
<td>2025-05-10 15:00:00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>采集复核</td>
|
||||
<td>王主任</td>
|
||||
<td><span class="badge badge-green">通过</span></td>
|
||||
<td style="color:#64748b">样本合格,流程规范</td>
|
||||
<td>2025-05-09 11:30:00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>缴费确认</td>
|
||||
<td>财务部 刘会计</td>
|
||||
<td><span class="badge badge-amber">部分完成</span></td>
|
||||
<td style="color:#64748b">已收到前两期款项,待尾款</td>
|
||||
<td>2025-05-12 11:00:00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div><!-- /dialog-body -->
|
||||
|
||||
<!-- ─── Footer ─── -->
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-close">关闭</button>
|
||||
</div>
|
||||
|
||||
</div><!-- /dialog -->
|
||||
</div><!-- /overlay -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,995 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>方案C:Tab真分页 — 采集协议单查看弹窗</title>
|
||||
<style>
|
||||
/* ===== Reset & Base ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #1e293b;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== Modal Overlay ===== */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* ===== Dialog ===== */
|
||||
.dialog {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
width: 1200px;
|
||||
max-height: calc(100vh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dialog Header */
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 32px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dialog-header h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.dialog-header h2 span {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.close-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* ===== Tab Bar ===== */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0 32px;
|
||||
flex-shrink: 0;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.tab-item {
|
||||
padding: 14px 24px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.25s ease;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
.tab-item:hover {
|
||||
color: #10b981;
|
||||
}
|
||||
.tab-item.active {
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
border-bottom-color: #10b981;
|
||||
}
|
||||
|
||||
/* ===== Tab Body (scrollable) ===== */
|
||||
.tab-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.tab-body::-webkit-scrollbar { width: 6px; }
|
||||
.tab-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.tab-body::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
.tab-body::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
|
||||
/* ===== Tab Panels ===== */
|
||||
.tab-panel {
|
||||
display: none;
|
||||
padding: 32px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ===== 3-Column Key-Value Grid ===== */
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px 32px;
|
||||
}
|
||||
.kv-grid .full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.kv-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.kv-item .label {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
.kv-item .value {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ===== Status Badge ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge.green {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.badge.yellow {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
.badge.blue {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
.badge.red {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
.badge.gray {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* ===== Section Title ===== */
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.section-title::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: #10b981;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.section-title span {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ===== Sub-section (for 客户信息) ===== */
|
||||
.sub-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.sub-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.sub-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.sub-section-header::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #10b981;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
.sub-section-header span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
.sub-section-divider {
|
||||
border: none;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
/* ===== File Thumbnails ===== */
|
||||
.file-list {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
cursor: default;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.file-thumb:hover {
|
||||
background: #ecfdf5;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.file-thumb .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ===== Table ===== */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.data-table thead th {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.data-table tbody td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
.data-table tbody tr:nth-child(even) {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.data-table tbody tr:hover {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
/* ===== Card ===== */
|
||||
.info-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.info-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.info-card .card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.info-card .card-title::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #10b981;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
/* ===== Payment Timeline ===== */
|
||||
.timeline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 24px 12px 8px;
|
||||
position: relative;
|
||||
gap: 0;
|
||||
}
|
||||
.timeline-step {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.timeline-step .dot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.timeline-step .dot.done {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
.timeline-step .dot.pending {
|
||||
background: #fff;
|
||||
border: 2px solid #d1d5db;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.timeline-step .dot.current {
|
||||
background: #fff;
|
||||
border: 2px solid #f59e0b;
|
||||
color: #d97706;
|
||||
}
|
||||
.timeline-step .line {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: calc(50% + 14px);
|
||||
right: calc(-50% + 14px);
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
z-index: 1;
|
||||
}
|
||||
.timeline-step .line.done {
|
||||
background: #10b981;
|
||||
}
|
||||
.timeline-step:last-child .line {
|
||||
display: none;
|
||||
}
|
||||
.timeline-step .step-label {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
max-width: 100px;
|
||||
}
|
||||
.timeline-step .step-amount {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.timeline-step .step-date {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ===== Payment Card Status Bar ===== */
|
||||
.payment-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.payment-status-bar.paid {
|
||||
background: #ecfdf5;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
.payment-status-bar.unpaid {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
.payment-status-bar .amount {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #059669;
|
||||
}
|
||||
.payment-status-bar.unpaid .amount {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
/* ===== Note / Remark Box ===== */
|
||||
.remark-box {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-left: 3px solid #10b981;
|
||||
border-radius: 6px;
|
||||
padding: 14px 18px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== Dialog Footer ===== */
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 32px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-close {
|
||||
padding: 9px 28px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-close:hover {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
/* ===== Responsive fallback ===== */
|
||||
@media (max-width: 1280px) {
|
||||
.dialog { width: 96vw; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.kv-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.tab-bar { overflow-x: auto; }
|
||||
.tab-body { padding: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="overlay">
|
||||
<div class="dialog">
|
||||
<!-- Header -->
|
||||
<div class="dialog-header">
|
||||
<h2>查看协议单 <span>— P2025-0501-001</span></h2>
|
||||
<button class="close-btn" title="关闭">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="tab-bar">
|
||||
<div class="tab-item active" data-tab="basic">基本信息</div>
|
||||
<div class="tab-item" data-tab="customer">客户信息</div>
|
||||
<div class="tab-item" data-tab="collect">采集信息</div>
|
||||
<div class="tab-item" data-tab="sample">样本及运输</div>
|
||||
<div class="tab-item" data-tab="payment">缴费信息</div>
|
||||
<div class="tab-item" data-tab="audit">审核记录</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Body -->
|
||||
<div class="tab-body">
|
||||
|
||||
<!-- ==================== 基本信息 ==================== -->
|
||||
<div class="tab-panel active" id="tab-basic">
|
||||
<div class="section-title"><span>协议基本信息</span></div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">协议号</span>
|
||||
<span class="value">P2025-0501-001</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">协议状态</span>
|
||||
<span class="value"><span class="badge green">● 生效中</span></span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">登记日期</span>
|
||||
<span class="value">2025-05-10</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">采集专员</span>
|
||||
<span class="value">李明</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span class="value">2025-05-10 14:30:22</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">更新时间</span>
|
||||
<span class="value">2025-05-10 15:12:08</span>
|
||||
</div>
|
||||
<div class="kv-item full-width">
|
||||
<span class="label">协议附件</span>
|
||||
<div class="file-list">
|
||||
<div class="file-thumb"><span class="icon">📄</span> 身份证正面.jpg</div>
|
||||
<div class="file-thumb"><span class="icon">📄</span> 身份证反面.jpg</div>
|
||||
<div class="file-thumb"><span class="icon">📑</span> 协议书.pdf</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv-item full-width">
|
||||
<span class="label">备注</span>
|
||||
<div class="remark-box">客户要求加急处理,已与采集专员沟通确认。母亲体质特殊需注意采集方式,采集时需由资深医护操作。已安排专人跟踪整个采集流程。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 客户信息 ==================== -->
|
||||
<div class="tab-panel" id="tab-customer">
|
||||
<!-- 母亲信息 -->
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-header"><span>母亲信息</span></div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">关联客户</span>
|
||||
<span class="value" style="color:#94a3b8;">—</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">姓名</span>
|
||||
<span class="value">王美丽</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">电话</span>
|
||||
<span class="value">138****5678</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">身份证号</span>
|
||||
<span class="value">3401****1234</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">出生年月</span>
|
||||
<span class="value">1992-06</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">民族</span>
|
||||
<span class="value">汉族</span>
|
||||
</div>
|
||||
<div class="kv-item full-width">
|
||||
<span class="label">住址</span>
|
||||
<span class="value">安徽省合肥市蜀山区望江西路XX小区X栋X单元XXX室</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="sub-section-divider">
|
||||
|
||||
<!-- 父亲信息 -->
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-header"><span>父亲信息</span></div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">姓名</span>
|
||||
<span class="value">张建国</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">电话</span>
|
||||
<span class="value">139****8901</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">身份证号</span>
|
||||
<span class="value">3401****5678</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="sub-section-divider">
|
||||
|
||||
<!-- 新生儿信息 -->
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-header"><span>新生儿信息</span></div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">姓名</span>
|
||||
<span class="value">张小明</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">身份证号</span>
|
||||
<span class="value" style="color:#94a3b8;">—</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">性别</span>
|
||||
<span class="value">男</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">分娩时间</span>
|
||||
<span class="value">2025-05-08 06:30</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">分娩方式</span>
|
||||
<span class="value">剖宫产</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">胎次</span>
|
||||
<span class="value">1</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">体重</span>
|
||||
<span class="value">3250g</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">评分 1min</span>
|
||||
<span class="value">9</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">评分 5min</span>
|
||||
<span class="value">10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 采集信息 ==================== -->
|
||||
<div class="tab-panel" id="tab-collect">
|
||||
<div class="section-title"><span>采集信息</span></div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">采集地区</span>
|
||||
<span class="value">华东地区</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">采集医院</span>
|
||||
<span class="value">合肥市第一人民医院</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">采集医护</span>
|
||||
<span class="value">陈护士</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">销售人员</span>
|
||||
<span class="value">赵经理</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">样本采集时间</span>
|
||||
<span class="value">2025-05-08 07:15:00</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">母血采集时间</span>
|
||||
<span class="value">2025-05-08 07:20:00</span>
|
||||
</div>
|
||||
<div class="kv-item full-width">
|
||||
<span class="label">采集备注</span>
|
||||
<div class="remark-box">新生儿体重3250g,评分正常。采集过程顺利,母血同步采集完毕,样本已做好标识并移交运输环节。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 28px;">
|
||||
<div class="section-title"><span>采集现场照片</span></div>
|
||||
<div class="file-list">
|
||||
<div class="file-thumb"><span class="icon">🖼️</span> 采集现场_01.jpg</div>
|
||||
<div class="file-thumb"><span class="icon">🖼️</span> 采集现场_02.jpg</div>
|
||||
<div class="file-thumb"><span class="icon">🖼️</span> 样本标识.jpg</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 样本及运输 ==================== -->
|
||||
<div class="tab-panel" id="tab-sample">
|
||||
<div class="section-title"><span>样本及运输信息</span></div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>样本类型</th>
|
||||
<th>样本编码</th>
|
||||
<th>采集量</th>
|
||||
<th>单位</th>
|
||||
<th>价格</th>
|
||||
<th>存储年限</th>
|
||||
<th>样本状态</th>
|
||||
<th>运输类型</th>
|
||||
<th>目的站点</th>
|
||||
<th>预计到达</th>
|
||||
<th>快递/携带</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>脐带血造血干细胞</td>
|
||||
<td>CB-20250508-001</td>
|
||||
<td>120</td>
|
||||
<td>ml</td>
|
||||
<td>¥20,000</td>
|
||||
<td>20年</td>
|
||||
<td><span class="badge green">已入库</span></td>
|
||||
<td>冷链快递</td>
|
||||
<td>上海实验室</td>
|
||||
<td>2025-05-09</td>
|
||||
<td>顺丰冷链</td>
|
||||
<td>温度正常</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>脐带组织</td>
|
||||
<td>CT-20250508-001</td>
|
||||
<td>15</td>
|
||||
<td>cm</td>
|
||||
<td>¥15,000</td>
|
||||
<td>20年</td>
|
||||
<td><span class="badge green">已入库</span></td>
|
||||
<td>冷链快递</td>
|
||||
<td>上海实验室</td>
|
||||
<td>2025-05-09</td>
|
||||
<td>顺丰冷链</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>胎盘干细胞</td>
|
||||
<td>PS-20250508-001</td>
|
||||
<td>80</td>
|
||||
<td>g</td>
|
||||
<td>¥15,000</td>
|
||||
<td>20年</td>
|
||||
<td><span class="badge blue">运输中</span></td>
|
||||
<td>专人携带</td>
|
||||
<td>北京实验室</td>
|
||||
<td>2025-05-10</td>
|
||||
<td>专人</td>
|
||||
<td>加急处理</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
<div class="section-title"><span>运输温度记录</span></div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">启运温度</span>
|
||||
<span class="value">2°C ~ 8°C</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">运输方式</span>
|
||||
<span class="value">冷链专用箱</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">预计全程温控</span>
|
||||
<span class="value">≤ 24 小时</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 缴费信息 ==================== -->
|
||||
<div class="tab-panel" id="tab-payment">
|
||||
<div class="section-title"><span>缴费概览</span></div>
|
||||
|
||||
<!-- 主缴费卡片 -->
|
||||
<div class="info-card">
|
||||
<div class="card-title">缴费主信息</div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">缴费模式</span>
|
||||
<span class="value">分期付款</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">合同金额</span>
|
||||
<span class="value" style="color:#059669; font-size:18px; font-weight:700;">¥50,000</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">分期期数</span>
|
||||
<span class="value">3 期</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">每期间隔</span>
|
||||
<span class="value">6 月</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">已缴金额</span>
|
||||
<span class="value" style="color:#10b981; font-weight:600;">¥16,667</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">待缴金额</span>
|
||||
<span class="value" style="color:#d97706; font-weight:600;">¥33,333</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缴费进度条 -->
|
||||
<div style="margin: 24px 0;">
|
||||
<div class="section-title"><span>缴费进度</span></div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-step">
|
||||
<div class="dot done">✓</div>
|
||||
<div class="line done"></div>
|
||||
<div class="step-label">第一期</div>
|
||||
<div class="step-amount">¥16,667</div>
|
||||
<div class="step-date">2025-05-10</div>
|
||||
</div>
|
||||
<div class="timeline-step">
|
||||
<div class="dot current">2</div>
|
||||
<div class="line"></div>
|
||||
<div class="step-label">第二期</div>
|
||||
<div class="step-amount">¥16,667</div>
|
||||
<div class="step-date">2025-11-08</div>
|
||||
</div>
|
||||
<div class="timeline-step">
|
||||
<div class="dot pending">3</div>
|
||||
<div class="step-label">第三期</div>
|
||||
<div class="step-amount">¥16,666</div>
|
||||
<div class="step-date">2026-05-08</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title"><span>缴费明细</span></div>
|
||||
|
||||
<!-- 缴费明细 Card 1 -->
|
||||
<div class="info-card">
|
||||
<div class="payment-status-bar paid">
|
||||
<div>
|
||||
<span style="font-size:13px; color:#64748b;">第一期 · 微信转账</span>
|
||||
</div>
|
||||
<div class="amount">¥16,667</div>
|
||||
<span class="badge green">已缴费</span>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">缴费时间</span>
|
||||
<span class="value">2025-05-10 16:20:35</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">缴费方式</span>
|
||||
<span class="value">微信转账</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">转账凭证</span>
|
||||
<div class="file-list" style="margin-top:2px;">
|
||||
<div class="file-thumb"><span class="icon">📄</span> 微信转账截图.jpg</div>
|
||||
<div class="file-thumb"><span class="icon">📄</span> 收据.jpg</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 缴费明细 Card 2 -->
|
||||
<div class="info-card">
|
||||
<div class="payment-status-bar unpaid">
|
||||
<div>
|
||||
<span style="font-size:13px; color:#64748b;">第二期 · 待缴费</span>
|
||||
</div>
|
||||
<div class="amount">¥16,667</div>
|
||||
<span class="badge yellow">待缴费</span>
|
||||
</div>
|
||||
<div class="kv-grid">
|
||||
<div class="kv-item">
|
||||
<span class="label">应缴费时间</span>
|
||||
<span class="value">2025-11-08</span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">缴费状态</span>
|
||||
<span class="value"><span class="badge yellow">待缴费</span></span>
|
||||
</div>
|
||||
<div class="kv-item">
|
||||
<span class="label">距缴费日</span>
|
||||
<span class="value">剩余 182 天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 审核记录 ==================== -->
|
||||
<div class="tab-panel" id="tab-audit">
|
||||
<div class="section-title"><span>审核记录</span></div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">序号</th>
|
||||
<th>审核时间</th>
|
||||
<th>审核状态</th>
|
||||
<th>审核备注</th>
|
||||
<th>审核人</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>2025-05-10 14:35:12</td>
|
||||
<td><span class="badge green">审核通过</span></td>
|
||||
<td>资料齐全,信息核实无误,同意通过。</td>
|
||||
<td>王主管</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>2025-05-10 10:22:45</td>
|
||||
<td><span class="badge red">审核退回</span></td>
|
||||
<td>新生儿分娩方式字段未填写,请补充完善后重新提交。</td>
|
||||
<td>王主管</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top: 28px;">
|
||||
<div class="section-title"><span>审核流程</span></div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-step">
|
||||
<div class="dot done">✓</div>
|
||||
<div class="line done"></div>
|
||||
<div class="step-label">提交审核</div>
|
||||
<div class="step-date">05-10 09:15</div>
|
||||
</div>
|
||||
<div class="timeline-step">
|
||||
<div class="dot done" style="background:#ef4444;">✗</div>
|
||||
<div class="line done"></div>
|
||||
<div class="step-label">审核退回</div>
|
||||
<div class="step-date">05-10 10:22</div>
|
||||
</div>
|
||||
<div class="timeline-step">
|
||||
<div class="dot done">✓</div>
|
||||
<div class="line done"></div>
|
||||
<div class="step-label">重新提交</div>
|
||||
<div class="step-date">05-10 14:28</div>
|
||||
</div>
|
||||
<div class="timeline-step">
|
||||
<div class="dot done">✓</div>
|
||||
<div class="step-label">审核通过</div>
|
||||
<div class="step-date">05-10 14:35</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /tab-body -->
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
const tabItems = document.querySelectorAll('.tab-item');
|
||||
const tabPanels = document.querySelectorAll('.tab-panel');
|
||||
|
||||
tabItems.forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const target = item.dataset.tab;
|
||||
|
||||
// Update tab bar
|
||||
tabItems.forEach(t => t.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
|
||||
// Update panels
|
||||
tabPanels.forEach(p => p.classList.remove('active'));
|
||||
const panel = document.getElementById('tab-' + target);
|
||||
if (panel) {
|
||||
panel.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -80,7 +80,7 @@
|
||||
const panelTitleClassList = panelTitleIconByText.map(([, cls]) => cls)
|
||||
const panelTitleBarClass = 'title-bar-heading'
|
||||
const panelTitleBarTextRules: RegExp[] = [
|
||||
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表)/
|
||||
/(数据概览|快捷菜单|分类列表|单位列表|商品列表|参数列表|用户列表|租户列表|区域列表|区域与询报价概览|报价管理|订单列表|最新订单|待结算订单|结算单记录|配送任务列表|预警列表|订单设置|客服设置|请求列表|近30日趋势|今日各区域|组织|组织管理|角色列表|菜单列表|字典类型|字典数据|站点列表|职位列表|快递公司列表|流程配置|流程列表|待办列表|已办列表|申请列表|客户列表|细胞样本类型列表|采集地区列表|采集协议号列表|样本编码列表|医院列表|协议单列表|审核记录列表|缴费确认列表|退费确认列表|分期预警列表|请假列表|洁净室列表|库位树|预约列表|缴费信息列表|样本接收列表|接收检查项列表|公司列表|项目列表|工单列表|任务列表)/
|
||||
]
|
||||
|
||||
const refreshPanelTitleIcons = () => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type QcItemParam = {
|
||||
id?: string
|
||||
paramName: string
|
||||
paramCode: string
|
||||
paramUnit?: string
|
||||
paramType: string
|
||||
requiredFlag: string
|
||||
orderNum?: number
|
||||
}
|
||||
|
||||
export function fetchQcItemParams(itemSource: string, itemId: string) {
|
||||
return request.get<QcItemParam[]>({
|
||||
url: '/web/admin/qc-item-params',
|
||||
params: { itemSource, itemId }
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSaveQcItemParams(itemSource: string, itemId: string, params: QcItemParam[]) {
|
||||
return request.put<QcItemParam[]>({
|
||||
url: '/web/admin/qc-item-params',
|
||||
params: { itemSource, itemId },
|
||||
data: { params }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type QcLabTestItem = {
|
||||
id: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
description?: string
|
||||
status: string
|
||||
orderNum: number
|
||||
remark?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export function fetchQcLabTestItemList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}) {
|
||||
return request.get<PageList<QcLabTestItem>>({
|
||||
url: '/web/admin/qc-lab-test-items',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchQcLabTestItemsActive() {
|
||||
return request.get<QcLabTestItem[]>({
|
||||
url: '/web/admin/qc-lab-test-items/active'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateQcLabTestItem(data: {
|
||||
itemCode: string
|
||||
itemName: string
|
||||
description?: string
|
||||
status?: string
|
||||
orderNum?: number
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post<QcLabTestItem>({
|
||||
url: '/web/admin/qc-lab-test-items',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateQcLabTestItem(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
itemCode: string
|
||||
itemName: string
|
||||
description: string
|
||||
status: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
}>
|
||||
) {
|
||||
return request.put<QcLabTestItem>({
|
||||
url: `/web/admin/qc-lab-test-items/${id}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDeleteQcLabTestItem(id: string) {
|
||||
return request.del({ url: `/web/admin/qc-lab-test-items/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
export type QcTaskResultItem = {
|
||||
paramId?: string
|
||||
paramName: string
|
||||
paramCode?: string
|
||||
paramUnit?: string
|
||||
paramType: string
|
||||
paramValue: string
|
||||
}
|
||||
|
||||
export function fetchQcTaskResults(taskId: string) {
|
||||
return request.get<QcTaskResultItem[]>({
|
||||
url: `/web/admin/qc-tasks/${taskId}/results`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCompleteTaskWithResult(
|
||||
taskId: string,
|
||||
data: { results: QcTaskResultItem[]; thirdPartyAttachments?: string }
|
||||
) {
|
||||
return request.post({
|
||||
url: `/web/admin/qc-tasks/${taskId}/complete-with-result`,
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type QcTask = {
|
||||
id: string
|
||||
workOrderId: string
|
||||
workOrderNo?: string
|
||||
taskNo: string
|
||||
taskType: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
companyId?: string
|
||||
companyName?: string
|
||||
feeAmount?: number
|
||||
taskStatus: string
|
||||
thirdPartyAttachments?: string
|
||||
completedTime?: string
|
||||
completedBy?: string
|
||||
remark?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export type QcTaskDetail = QcTask & {
|
||||
protocolNo?: string
|
||||
sampleCode?: string
|
||||
attachmentFiles?: Array<{ id?: string; fileUrl: string; fileName?: string }>
|
||||
results?: Array<{
|
||||
paramName: string
|
||||
paramUnit?: string
|
||||
paramType: string
|
||||
paramValue: string
|
||||
}>
|
||||
}
|
||||
|
||||
export function fetchQcTaskList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
taskType?: string
|
||||
taskStatus?: string
|
||||
workOrderId?: string
|
||||
}) {
|
||||
return request.get<PageList<QcTask>>({
|
||||
url: '/web/admin/qc-tasks',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchQcTaskDetail(id: string) {
|
||||
return request.get<QcTaskDetail>({
|
||||
url: `/web/admin/qc-tasks/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchStartQcTask(id: string) {
|
||||
return request.post<QcTask>({
|
||||
url: `/web/admin/qc-tasks/${id}/start`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCompleteQcTask(id: string) {
|
||||
return request.post<QcTask>({
|
||||
url: `/web/admin/qc-tasks/${id}/complete`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateQcTaskAttachments(id: string, thirdPartyAttachments: string) {
|
||||
return request.put<QcTask>({
|
||||
url: `/web/admin/qc-tasks/${id}/attachments`,
|
||||
data: { thirdPartyAttachments }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type QcThirdPartyCompany = {
|
||||
id: string
|
||||
companyCode: string
|
||||
companyName: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
address?: string
|
||||
invoiceTitle?: string
|
||||
taxId?: string
|
||||
bankName?: string
|
||||
bankAccount?: string
|
||||
status: string
|
||||
orderNum: number
|
||||
remark?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export function fetchQcThirdPartyCompanyList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}) {
|
||||
return request.get<PageList<QcThirdPartyCompany>>({
|
||||
url: '/web/admin/qc-third-party-companies',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchQcThirdPartyCompanyOptions() {
|
||||
return request.get<Array<{ id: string; companyCode: string; companyName: string }>>({
|
||||
url: '/web/admin/qc-third-party-companies/options'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateQcThirdPartyCompany(data: {
|
||||
companyCode: string
|
||||
companyName: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
address?: string
|
||||
invoiceTitle?: string
|
||||
taxId?: string
|
||||
bankName?: string
|
||||
bankAccount?: string
|
||||
status?: string
|
||||
orderNum?: number
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post<QcThirdPartyCompany>({
|
||||
url: '/web/admin/qc-third-party-companies',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateQcThirdPartyCompany(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
companyCode: string
|
||||
companyName: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
address: string
|
||||
invoiceTitle: string
|
||||
taxId: string
|
||||
bankName: string
|
||||
bankAccount: string
|
||||
status: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
}>
|
||||
) {
|
||||
return request.put<QcThirdPartyCompany>({
|
||||
url: `/web/admin/qc-third-party-companies/${id}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDeleteQcThirdPartyCompany(id: string) {
|
||||
return request.del({ url: `/web/admin/qc-third-party-companies/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type QcThirdPartyItem = {
|
||||
id: string
|
||||
companyId: string
|
||||
companyName?: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
feeAmount: number
|
||||
status: string
|
||||
orderNum: number
|
||||
remark?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export function fetchQcThirdPartyItemList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
companyId?: string
|
||||
}) {
|
||||
return request.get<PageList<QcThirdPartyItem>>({
|
||||
url: '/web/admin/qc-third-party-items',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchQcThirdPartyItemsActive(companyId?: string) {
|
||||
return request.get<QcThirdPartyItem[]>({
|
||||
url: '/web/admin/qc-third-party-items/active',
|
||||
params: companyId ? { companyId } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateQcThirdPartyItem(data: {
|
||||
companyId: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
feeAmount: number
|
||||
status?: string
|
||||
orderNum?: number
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post<QcThirdPartyItem>({
|
||||
url: '/web/admin/qc-third-party-items',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateQcThirdPartyItem(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
companyId: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
feeAmount: number
|
||||
status: string
|
||||
orderNum: number
|
||||
remark: string
|
||||
}>
|
||||
) {
|
||||
return request.put<QcThirdPartyItem>({
|
||||
url: `/web/admin/qc-third-party-items/${id}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDeleteQcThirdPartyItem(id: string) {
|
||||
return request.del({ url: `/web/admin/qc-third-party-items/${id}` })
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type QcWorkOrder = {
|
||||
id: string
|
||||
workOrderNo: string
|
||||
receptionId: string
|
||||
protocolSampleId: string
|
||||
collectionProtocolId: string
|
||||
protocolNo: string
|
||||
sampleCode: string
|
||||
workOrderStatus: string
|
||||
hasLab: boolean
|
||||
hasThirdParty: boolean
|
||||
configuredTime?: string
|
||||
configuredBy?: string
|
||||
thirdPartyFeeTotal?: number
|
||||
remark?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export type QcWorkOrderDetail = QcWorkOrder & {
|
||||
reception?: {
|
||||
receptionTime?: string
|
||||
handlerName?: string
|
||||
acceptConclusion?: string
|
||||
}
|
||||
labItems?: Array<{ id: string; labTestItemId: string; itemCode: string; itemName: string }>
|
||||
thirdPartyItems?: Array<{
|
||||
id: string
|
||||
thirdPartyItemId: string
|
||||
companyId: string
|
||||
companyName: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
feeAmount: number
|
||||
}>
|
||||
tasks?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export function fetchQcWorkOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
workOrderStatus?: string
|
||||
}) {
|
||||
return request.get<PageList<QcWorkOrder>>({
|
||||
url: '/web/admin/qc-work-orders',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchQcWorkOrderDetail(id: string) {
|
||||
return request.get<QcWorkOrderDetail>({
|
||||
url: `/web/admin/qc-work-orders/${id}`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchConfigureQcWorkOrder(
|
||||
id: string,
|
||||
data: {
|
||||
hasLab: boolean
|
||||
hasThirdParty: boolean
|
||||
labItemIds: string[]
|
||||
thirdPartyItemIds: string[]
|
||||
}
|
||||
) {
|
||||
return request.post<QcWorkOrder>({
|
||||
url: `/web/admin/qc-work-orders/${id}/configure`,
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export type SampleReceptionCheckItem = {
|
||||
id: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
status: string
|
||||
orderNum: number
|
||||
remark?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export function fetchSampleReceptionCheckItemList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
}) {
|
||||
return request.get<PageList<SampleReceptionCheckItem>>({
|
||||
url: '/web/admin/sample-reception-check-items',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSampleReceptionCheckItemsActive() {
|
||||
return request.get<SampleReceptionCheckItem[]>({
|
||||
url: '/web/admin/sample-reception-check-items/active'
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCreateSampleReceptionCheckItem(data: {
|
||||
itemCode: string
|
||||
itemName: string
|
||||
status?: string
|
||||
orderNum?: number
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post<SampleReceptionCheckItem>({
|
||||
url: '/web/admin/sample-reception-check-items',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchUpdateSampleReceptionCheckItem(
|
||||
id: string,
|
||||
data: {
|
||||
itemCode?: string
|
||||
itemName?: string
|
||||
status?: string
|
||||
orderNum?: number
|
||||
remark?: string
|
||||
}
|
||||
) {
|
||||
return request.put<SampleReceptionCheckItem>({
|
||||
url: `/web/admin/sample-reception-check-items/${id}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDeleteSampleReceptionCheckItem(id: string) {
|
||||
return request.del({
|
||||
url: `/web/admin/sample-reception-check-items/${id}`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
type PageList<T> = {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export function fetchSampleReceptionPendingList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get<PageList<Record<string, unknown>>>({
|
||||
url: '/web/admin/sample-receptions/pending',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSampleReceptionReceivedList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get<PageList<Record<string, unknown>>>({
|
||||
url: '/web/admin/sample-receptions/received',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSampleReceptionDetail(receptionId: string) {
|
||||
return request.get<Record<string, unknown>>({
|
||||
url: `/web/admin/sample-receptions/${receptionId}`
|
||||
})
|
||||
}
|
||||
|
||||
export type SampleReceptionCheckResult = {
|
||||
id?: string
|
||||
checkItemId?: string
|
||||
itemCode: string
|
||||
itemName: string
|
||||
checked: boolean
|
||||
orderNum?: number
|
||||
}
|
||||
|
||||
export function fetchSampleReceptionSubmit(data: {
|
||||
protocolSampleId: string
|
||||
receptionTime: string
|
||||
handlerUserId?: string
|
||||
handlerName?: string
|
||||
receiveTemperature?: string
|
||||
volumeMl?: number
|
||||
netWeightG?: number
|
||||
checks: Array<{ checkItemId: string; checked: boolean }>
|
||||
acceptConclusion: 'js' | 'bj'
|
||||
receiveAttachments?: string
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({
|
||||
url: '/web/admin/sample-receptions',
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -486,6 +486,7 @@ body {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.list-toolbar,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 财务确认弹窗:附件上传 + 财务备注(对齐采集协议单附件 UI) -->
|
||||
<template>
|
||||
<div class="protocol-finance-confirm-fields">
|
||||
<el-form-item label="财务备注" class="form-col-span-2">
|
||||
<el-form-item :label="remarkLabel" class="form-col-span-2">
|
||||
<el-input
|
||||
:model-value="financeRemark"
|
||||
type="textarea"
|
||||
@@ -10,7 +10,7 @@
|
||||
@update:model-value="emit('update:financeRemark', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="财务确认附件" class="form-col-span-2">
|
||||
<el-form-item :label="attachmentLabel" class="form-col-span-2">
|
||||
<div class="attachment-upload">
|
||||
<div class="attachment-list">
|
||||
<el-upload
|
||||
@@ -94,8 +94,14 @@
|
||||
financeRemark: string
|
||||
attachments: FinanceAttachmentItem[]
|
||||
uploadFolder?: string
|
||||
remarkLabel?: string
|
||||
attachmentLabel?: string
|
||||
}>(),
|
||||
{ uploadFolder: 'protocol-finance-confirm' }
|
||||
{
|
||||
uploadFolder: 'protocol-finance-confirm',
|
||||
remarkLabel: '财务备注',
|
||||
attachmentLabel: '财务确认附件'
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -144,6 +150,14 @@
|
||||
emit('update:attachments', next)
|
||||
}
|
||||
|
||||
/** Mutable snapshot used to avoid race conditions when multiple files are picked at once */
|
||||
let _pendingList: FinanceAttachmentItem[] | null = null
|
||||
|
||||
function _getList(): FinanceAttachmentItem[] {
|
||||
if (_pendingList) return _pendingList
|
||||
return props.attachments
|
||||
}
|
||||
|
||||
async function handleAttachmentChange(uploadFile: { raw?: File }) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
@@ -152,7 +166,7 @@
|
||||
ElMessage.error(`文件大小不能超过 ${maxSizeMb}MB`)
|
||||
return
|
||||
}
|
||||
const tempId = `temp_${Date.now()}`
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
const uploading: FinanceAttachmentItem = {
|
||||
id: tempId,
|
||||
fileName: file.name,
|
||||
@@ -160,29 +174,38 @@
|
||||
fileType: file.type || '',
|
||||
_uploading: true
|
||||
}
|
||||
const listWithTemp = [...props.attachments, uploading]
|
||||
// Append to the latest snapshot so concurrent calls don't overwrite each other
|
||||
const current = _getList()
|
||||
const listWithTemp = [...current, uploading]
|
||||
_pendingList = listWithTemp
|
||||
emit('update:attachments', listWithTemp)
|
||||
const idx = listWithTemp.length - 1
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('folder', props.uploadFolder)
|
||||
fd.append('file', file)
|
||||
const res = await fetchUploadAttachment(fd)
|
||||
const updated = [...listWithTemp]
|
||||
updated[idx] = {
|
||||
id: res.id || tempId,
|
||||
fileName: file.name || res.fileName,
|
||||
fileUrl: res.fileUrl,
|
||||
fileType: res.fileType || file.type || '',
|
||||
_uploading: false
|
||||
}
|
||||
// Read from latest snapshot (NOT the closure-captured listWithTemp) to avoid
|
||||
// overwriting sibling uploads that may have already completed while this one was in-flight
|
||||
const latest = _pendingList || props.attachments
|
||||
const updated = latest.map((a) =>
|
||||
a.id === tempId
|
||||
? {
|
||||
id: res.id || tempId,
|
||||
fileName: file.name || res.fileName,
|
||||
fileUrl: res.fileUrl,
|
||||
fileType: res.fileType || file.type || '',
|
||||
_uploading: false
|
||||
}
|
||||
: a
|
||||
)
|
||||
_pendingList = updated
|
||||
emit('update:attachments', updated)
|
||||
} catch {
|
||||
ElMessage.error('文件上传失败')
|
||||
emit(
|
||||
'update:attachments',
|
||||
props.attachments.filter((a) => a.id !== tempId)
|
||||
)
|
||||
const latest = _pendingList || props.attachments
|
||||
const remaining = latest.filter((a) => a.id !== tempId)
|
||||
_pendingList = remaining.length > 0 ? remaining : null
|
||||
emit('update:attachments', remaining)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -203,7 +226,7 @@
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
width: 120px;
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
border: 1px dashed #a7f3d0;
|
||||
border-radius: 8px;
|
||||
@@ -236,18 +259,26 @@
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.4;
|
||||
line-height: 1;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
width: 120px;
|
||||
width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
&.is-uploading {
|
||||
opacity: 0.6;
|
||||
@@ -263,58 +294,84 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-preview__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
|
||||
&--video {
|
||||
color: #10b981;
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
&--doc {
|
||||
background: #e0f2fe;
|
||||
color: #0284c7;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
min-height: calc(11px * 1.4 * 2);
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
background: #f1f5f9;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: #10b981;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -46,7 +46,7 @@
|
||||
<dd>{{ formatPaymentDueDate(detail?.installmentDueDate) }}</dd>
|
||||
</div>
|
||||
<div class="view-kv">
|
||||
<dt>应缴费金额</dt>
|
||||
<dt>应缴费金额(元)</dt>
|
||||
<dd>{{ formatPaymentAmount(detail?.dueAmount) }}</dd>
|
||||
</div>
|
||||
<div class="view-kv">
|
||||
@@ -54,7 +54,7 @@
|
||||
<dd>{{ transferTypeLabel(String(detail?.transferType || '')) }}</dd>
|
||||
</div>
|
||||
<div class="view-kv">
|
||||
<dt>转账金额</dt>
|
||||
<dt>转账金额(元)</dt>
|
||||
<dd>{{ formatPaymentAmount(detail?.transferAmount) }}</dd>
|
||||
</div>
|
||||
<div class="view-kv">
|
||||
@@ -80,7 +80,7 @@
|
||||
<h4 v-if="refundApplyTitle" class="detail-block-title">{{ refundApplyTitle }}</h4>
|
||||
<dl class="view-kv-grid view-kv-grid--4">
|
||||
<div class="view-kv">
|
||||
<dt>退费金额</dt>
|
||||
<dt>退费金额(元)</dt>
|
||||
<dd>{{ formatPaymentAmount(detail?.refundAmount) }}</dd>
|
||||
</div>
|
||||
<div v-if="detail?.refundRemark" class="view-kv view-kv-span-2">
|
||||
|
||||
@@ -19,6 +19,97 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
menuCode: 'dashboard'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '100',
|
||||
name: 'System',
|
||||
path: '/system',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '系统管理',
|
||||
icon: 'ri:settings-3-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '101',
|
||||
name: 'Tenant',
|
||||
path: 'tenant',
|
||||
component: '/system/tenant/index',
|
||||
meta: { title: '租户管理', keepAlive: true, menuCode: 'system:tenant:list' }
|
||||
},
|
||||
{
|
||||
id: '102',
|
||||
name: 'Dict',
|
||||
path: 'dict',
|
||||
component: '/system/dict/index',
|
||||
meta: { title: '字典管理', keepAlive: true, menuCode: 'system:dict:list' }
|
||||
},
|
||||
{
|
||||
id: '103',
|
||||
name: 'Dept',
|
||||
path: 'dept',
|
||||
component: '/system/dept/index',
|
||||
meta: { title: '组织管理', keepAlive: true, menuCode: 'system:dept:list' }
|
||||
},
|
||||
{
|
||||
id: '104',
|
||||
name: 'User',
|
||||
path: 'user',
|
||||
component: '/system/user/index',
|
||||
meta: { title: '用户管理', keepAlive: true, menuCode: 'system:user:list' }
|
||||
},
|
||||
{
|
||||
id: '10000000000000000000000000000008',
|
||||
name: 'UserDetail',
|
||||
path: 'user/detail',
|
||||
component: '/system/user/detail',
|
||||
meta: { title: '用户详情', isHide: true, keepAlive: true }
|
||||
},
|
||||
{
|
||||
id: '105',
|
||||
name: 'Role',
|
||||
path: 'role',
|
||||
component: '/system/role/index',
|
||||
meta: { title: '角色管理', keepAlive: true, menuCode: 'system:role:list' }
|
||||
},
|
||||
{
|
||||
id: '106',
|
||||
name: 'Menu',
|
||||
path: 'menu',
|
||||
component: '/system/menu/index',
|
||||
meta: { title: '菜单管理', keepAlive: true, menuCode: 'system:menu:list' }
|
||||
},
|
||||
{
|
||||
id: '107',
|
||||
name: 'Site',
|
||||
path: 'site',
|
||||
component: '/system/site/index',
|
||||
meta: { title: '站点管理', keepAlive: true, menuCode: 'system:site:list' }
|
||||
},
|
||||
{
|
||||
id: '108',
|
||||
name: 'Post',
|
||||
path: 'post',
|
||||
component: '/system/post/index',
|
||||
meta: { title: '职位管理', keepAlive: true, menuCode: 'system:post:list' }
|
||||
},
|
||||
{
|
||||
id: '109',
|
||||
name: 'Unit',
|
||||
path: 'unit',
|
||||
component: '/system/unit/index',
|
||||
meta: { title: '物品单位', keepAlive: true, menuCode: 'system:unit:list' }
|
||||
},
|
||||
{
|
||||
id: '110',
|
||||
name: 'Express',
|
||||
path: 'express',
|
||||
component: '/system/express/index',
|
||||
meta: { title: '快递公司', keepAlive: true, menuCode: 'system:express:list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '60000000000000000000000000000001',
|
||||
name: 'Operations',
|
||||
@@ -159,6 +250,82 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000001',
|
||||
name: 'QualityInspection',
|
||||
path: '/quality-inspection',
|
||||
component: '/index/index',
|
||||
meta: {
|
||||
title: '质量检测',
|
||||
icon: 'ri:shield-check-line',
|
||||
keepAlive: false,
|
||||
roles: ['admin']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: '70000000000000000000000000000002',
|
||||
name: 'QualityInspectionSampleReception',
|
||||
path: 'sample-reception',
|
||||
component: '/quality-inspection/sample-reception/index',
|
||||
meta: { title: '样本接收', keepAlive: true, menuCode: 'biz:sampleReception:list' }
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000003',
|
||||
name: 'QualityInspectionSampleReceptionCheckItem',
|
||||
path: 'sample-reception-check-item',
|
||||
component: '/quality-inspection/sample-reception-check-item/index',
|
||||
meta: {
|
||||
title: '接收检查项配置',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:sampleReceptionCheckItem:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000004',
|
||||
name: 'QualityInspectionThirdPartyCompany',
|
||||
path: 'third-party-company',
|
||||
component: '/quality-inspection/third-party-company/index',
|
||||
meta: {
|
||||
title: '第三方检测公司及项目',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:qcThirdPartyCompany:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000006',
|
||||
name: 'QualityInspectionLabTestItem',
|
||||
path: 'lab-test-item',
|
||||
component: '/quality-inspection/lab-test-item/index',
|
||||
meta: {
|
||||
title: '实验室自检项目',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:qcLabTestItem:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000007',
|
||||
name: 'QualityInspectionQcWorkOrder',
|
||||
path: 'qc-work-order',
|
||||
component: '/quality-inspection/qc-work-order/index',
|
||||
meta: {
|
||||
title: '质检工单',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:qcWorkOrder:list'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '70000000000000000000000000000008',
|
||||
name: 'QualityInspectionQcTask',
|
||||
path: 'qc-task',
|
||||
component: '/quality-inspection/qc-task/index',
|
||||
meta: {
|
||||
title: '质检任务',
|
||||
keepAlive: true,
|
||||
menuCode: 'biz:qcTask:list'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '30000000000000000000000000000001',
|
||||
name: 'Workflow',
|
||||
|
||||
Vendored
+4
@@ -542,6 +542,8 @@ declare namespace Api {
|
||||
id: string
|
||||
protocolNo: string
|
||||
issued: number
|
||||
/** 是否已在采集协议单主表占用(与号池「是否下发」无关) */
|
||||
usedInProtocol?: boolean
|
||||
issuedUserId: string
|
||||
issuedUserName: string
|
||||
issuedTime: string
|
||||
@@ -559,6 +561,8 @@ declare namespace Api {
|
||||
sampleCode: string
|
||||
sampleCodeType: string
|
||||
issued: number
|
||||
/** 是否已在协议单样本表中占用(与号池「是否下发」无关) */
|
||||
usedInProtocol?: boolean
|
||||
issuedUserId: string
|
||||
issuedUserName: string
|
||||
issuedTime: string
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/** 分期缴费:期数对齐、应缴日/金额计算(与 ProtocolFormDialog 逻辑一致) */
|
||||
|
||||
export function addMonthsYmd(dateStr: string, months: number): string {
|
||||
const compact = String(dateStr || '').replace(/-/g, '')
|
||||
if (compact.length !== 8) return ''
|
||||
const y = parseInt(compact.slice(0, 4), 10)
|
||||
const m = parseInt(compact.slice(4, 6), 10) - 1
|
||||
const d = parseInt(compact.slice(6, 8), 10)
|
||||
const date = new Date(y, m + months, d)
|
||||
const yy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yy}${mm}${dd}`
|
||||
}
|
||||
|
||||
export function calcInstallmentDueAmounts(
|
||||
contractAmount: number | null | undefined,
|
||||
count: number
|
||||
): number[] {
|
||||
if (contractAmount == null || !count || count < 1) return []
|
||||
const totalCents = Math.round(Number(contractAmount) * 100)
|
||||
const baseCents = Math.floor(totalCents / count)
|
||||
const amounts: number[] = []
|
||||
let sumCents = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (i === count - 1) {
|
||||
amounts.push((totalCents - sumCents) / 100)
|
||||
} else {
|
||||
amounts.push(baseCents / 100)
|
||||
sumCents += baseCents
|
||||
}
|
||||
}
|
||||
return amounts
|
||||
}
|
||||
|
||||
type PaymentLike = Record<string, unknown> | null | undefined
|
||||
type DetailLike = Record<string, any>
|
||||
|
||||
/**
|
||||
* 分期模式下按 installmentCount 补齐缺失期次(保留已有明细数据),仅用于编辑表单。
|
||||
*/
|
||||
export function alignFqPaymentDetailsForDisplay(
|
||||
payment: PaymentLike,
|
||||
details: DetailLike[]
|
||||
): DetailLike[] {
|
||||
if (!payment || payment.payMode !== 'fq') return details
|
||||
const count = Number(payment.installmentCount)
|
||||
if (!count || count < 1) return details
|
||||
|
||||
const rows = [...details]
|
||||
const byNo = new Map<number, DetailLike>()
|
||||
const unnumbered: DetailLike[] = []
|
||||
for (const row of rows) {
|
||||
const no = row.installmentNo
|
||||
if (no != null && no !== '') {
|
||||
byNo.set(Number(no), row)
|
||||
} else {
|
||||
unnumbered.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
const needed = new Set(Array.from({ length: count }, (_, i) => i + 1))
|
||||
const have = new Set(byNo.keys())
|
||||
let missing = false
|
||||
for (const n of needed) {
|
||||
if (!have.has(n)) {
|
||||
missing = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!missing && rows.length >= count) {
|
||||
return sortFqPaymentDetails(rows).slice(0, count)
|
||||
}
|
||||
|
||||
const contract = payment.contractAmount != null ? Number(payment.contractAmount) : null
|
||||
const amounts = calcInstallmentDueAmounts(contract, count)
|
||||
const startDate = String(payment.installmentStartDate || '')
|
||||
const interval = Number(payment.installmentIntervalMonths || 0)
|
||||
|
||||
const result: DetailLike[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const no = i + 1
|
||||
if (byNo.has(no)) {
|
||||
result.push(byNo.get(no)!)
|
||||
continue
|
||||
}
|
||||
if (i < unnumbered.length) {
|
||||
const raw = { ...unnumbered[i], installmentNo: no }
|
||||
if (!raw.installmentDueDate && startDate && interval) {
|
||||
raw.installmentDueDate = addMonthsYmd(startDate, i * interval)
|
||||
}
|
||||
result.push(raw)
|
||||
continue
|
||||
}
|
||||
result.push({
|
||||
installmentNo: no,
|
||||
installmentDueDate:
|
||||
startDate && interval ? addMonthsYmd(startDate, i * interval) : '',
|
||||
dueAmount: amounts[i] ?? null,
|
||||
payStatus: 'dfj',
|
||||
transferType: '',
|
||||
transferAmount: null,
|
||||
attachments: []
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function sortFqPaymentDetails(details: DetailLike[]): DetailLike[] {
|
||||
return [...details].sort((a, b) => {
|
||||
const na = a.installmentNo != null && a.installmentNo !== '' ? Number(a.installmentNo) : 9999
|
||||
const nb = b.installmentNo != null && b.installmentNo !== '' ? Number(b.installmentNo) : 9999
|
||||
if (na !== nb) return na - nb
|
||||
return Number(a.orderNum || 0) - Number(b.orderNum || 0)
|
||||
})
|
||||
}
|
||||
|
||||
/** 编辑表单:将缴费明细行数与分期期数对齐(不覆盖已有行) */
|
||||
export function syncFqPaymentDetailRowCount(
|
||||
payment: {
|
||||
payMode: string
|
||||
installmentCount: number | null
|
||||
contractAmount: number | null
|
||||
installmentStartDate: string
|
||||
installmentIntervalMonths: number | null
|
||||
},
|
||||
paymentDetails: DetailLike[]
|
||||
): void {
|
||||
if (payment.payMode !== 'fq') return
|
||||
const count = Number(payment.installmentCount)
|
||||
if (!count || count < 1) return
|
||||
|
||||
const aligned = alignFqPaymentDetailsForDisplay(payment, paymentDetails)
|
||||
paymentDetails.splice(0, paymentDetails.length, ...aligned)
|
||||
}
|
||||
@@ -78,5 +78,5 @@ export function formatPaymentAmount(v: unknown) {
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return String(v)
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function workOrderStatusLabel(v: string) {
|
||||
const m: Record<string, string> = {
|
||||
dpz: '待配置',
|
||||
ypz: '已配置',
|
||||
ywc: '已完成'
|
||||
}
|
||||
return m[v] || v || '—'
|
||||
}
|
||||
|
||||
export function taskTypeLabel(v: string) {
|
||||
const m: Record<string, string> = {
|
||||
sysz: '实验室自检',
|
||||
dsfjc: '第三方检测'
|
||||
}
|
||||
return m[v] || v || '—'
|
||||
}
|
||||
|
||||
export function taskStatusLabel(v: string) {
|
||||
const m: Record<string, string> = {
|
||||
dzx: '待执行',
|
||||
jxz: '进行中',
|
||||
ywc: '已完成'
|
||||
}
|
||||
return m[v] || v || '—'
|
||||
}
|
||||
@@ -34,11 +34,12 @@
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增类型
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAuth('delete')"
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@@ -116,8 +117,8 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -218,6 +219,8 @@
|
||||
fetchDeleteCellSampleType
|
||||
} from '@/api/admin'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
defineOptions({ name: 'BizCellSampleType' })
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增协议号
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="openBatchGenerate">
|
||||
<el-button v-if="hasAuth('add')" class="btn-ghost" @click="openBatchGenerate">
|
||||
批量生成
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -56,6 +56,7 @@
|
||||
批量收回
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAuth('delete')"
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@@ -137,8 +138,8 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -376,6 +377,8 @@
|
||||
fetchBatchIssueProtocolNos,
|
||||
fetchBatchRevokeProtocolNos
|
||||
} from '@/api/admin'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
defineOptions({ name: 'BizCollectionProtocolNo' })
|
||||
|
||||
|
||||
@@ -34,11 +34,12 @@
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增地区
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAuth('delete')"
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@@ -113,8 +114,8 @@
|
||||
<el-button class="btn-op-item primary" @click="openHospitalDialog(row)"
|
||||
>地区医院配置</el-button
|
||||
>
|
||||
<el-button class="btn-op-item" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="hasAuth('edit')" class="btn-op-item" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -345,6 +346,8 @@
|
||||
fetchUpdateHospital,
|
||||
fetchDeleteHospital
|
||||
} from '@/api/admin'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
defineOptions({ name: 'BizCollectionRegion' })
|
||||
|
||||
|
||||
@@ -38,11 +38,12 @@
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增客户
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAuth('delete')"
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@@ -125,8 +126,8 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -235,6 +236,8 @@
|
||||
fetchDeleteCustomer
|
||||
} from '@/api/admin'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
defineOptions({ name: 'BizCustomer' })
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-button v-if="hasAuth('add')" class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增样本编码
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="openBatchGenerate">
|
||||
<el-button v-if="hasAuth('add')" class="btn-ghost" @click="openBatchGenerate">
|
||||
批量生成
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -56,6 +56,7 @@
|
||||
批量收回
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="hasAuth('delete')"
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@@ -146,8 +147,8 @@
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="hasAuth('edit')" class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button v-if="hasAuth('delete')" class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -391,6 +392,8 @@
|
||||
fetchBatchIssueSampleCodes,
|
||||
fetchBatchRevokeSampleCodes
|
||||
} from '@/api/admin'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
defineOptions({ name: 'BizSampleCode' })
|
||||
|
||||
|
||||
@@ -560,7 +560,7 @@
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
if (Number.isNaN(n)) return String(v)
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
return '¥' + n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
|
||||
@@ -60,6 +60,18 @@
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
审核
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('activate')">
|
||||
协议生效
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('expire')">
|
||||
协议过期
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('void')">
|
||||
协议作废
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="openStatusChangeDialog('toResearch')">
|
||||
协议转科研
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedRows.length" @click="handleBatchDelete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
批量删除
|
||||
@@ -261,6 +273,14 @@
|
||||
@submitted="onAuditSubmitted"
|
||||
/>
|
||||
|
||||
<!-- 状态变更弹窗(生效/过期/作废/转科研) -->
|
||||
<ProtocolStatusChangeDialog
|
||||
v-model="statusChangeDialogVisible"
|
||||
:action-type="statusChangeAction"
|
||||
:targets="statusChangeTargets"
|
||||
@submitted="onStatusChangeSubmitted"
|
||||
/>
|
||||
|
||||
<!-- 查看弹窗 -->
|
||||
<ProtocolViewDialog
|
||||
v-model="viewVisible"
|
||||
@@ -288,6 +308,10 @@
|
||||
import ProtocolAuditDialog, {
|
||||
type ProtocolAuditTarget
|
||||
} from '../protocol/ProtocolAuditDialog.vue'
|
||||
import ProtocolStatusChangeDialog, {
|
||||
type StatusChangeAction,
|
||||
type StatusChangeTarget
|
||||
} from '../protocol/ProtocolStatusChangeDialog.vue'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import {
|
||||
fetchCollectionProtocolList,
|
||||
@@ -417,6 +441,36 @@
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 状态变更(生效/过期/作废/转科研) ====== */
|
||||
const statusChangeDialogVisible = ref(false)
|
||||
const statusChangeAction = ref<StatusChangeAction>('activate')
|
||||
const statusChangeTargets = ref<StatusChangeTarget[]>([])
|
||||
|
||||
function openStatusChangeDialog(action: StatusChangeAction, row?: CollectionProtocolRow) {
|
||||
const rows = row ? [row] : selectedRows.value
|
||||
if (!rows.length) {
|
||||
const labels: Record<StatusChangeAction, string> = {
|
||||
activate: '协议生效',
|
||||
expire: '协议过期',
|
||||
void: '协议作废',
|
||||
toResearch: '协议转科研'
|
||||
}
|
||||
ElMessage.warning(`请先选择要操作的协议单`)
|
||||
return
|
||||
}
|
||||
statusChangeAction.value = action
|
||||
statusChangeTargets.value = rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
protocolNo: r.protocolNo != null ? String(r.protocolNo) : undefined
|
||||
}))
|
||||
statusChangeDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onStatusChangeSubmitted() {
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}
|
||||
|
||||
/* ====== 删除 ====== */
|
||||
function handleDelete(row: CollectionProtocolRow) {
|
||||
ElMessageBox.confirm(`确认删除协议号「${row.protocolNo}」?`, '删除确认', {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,222 @@
|
||||
<!-- 采集协议单 — 状态变更弹窗(协议生效 / 协议过期 / 协议作废 / 协议转科研) -->
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<p v-if="targets.length > 1" class="status-change-hint">
|
||||
将对已选
|
||||
<strong>{{ targets.length }}</strong> 条协议单执行<strong>{{ actionLabel }}</strong
|
||||
>操作,每条各记一次变更。
|
||||
</p>
|
||||
<p v-else-if="targets.length === 1" class="status-change-hint">
|
||||
协议号:<strong>{{ targets[0].protocolNo || '—' }}</strong>
|
||||
</p>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="biz-dialog-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item :label="timeLabel" prop="changeTime">
|
||||
<el-date-picker
|
||||
v-model="form.changeTime"
|
||||
type="datetime"
|
||||
placeholder="请选择时间"
|
||||
style="width: 100%"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="descLabel">
|
||||
<el-input
|
||||
v-model="form.changeDesc"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="可选"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitting" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { fetchUpdateCollectionProtocol } from '@/api/collection-protocol'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
|
||||
export type StatusChangeAction = 'activate' | 'expire' | 'void' | 'toResearch'
|
||||
export type StatusChangeTarget = {
|
||||
id: string
|
||||
protocolNo?: string
|
||||
}
|
||||
|
||||
defineOptions({ name: 'ProtocolStatusChangeDialog' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
actionType: StatusChangeAction
|
||||
targets: StatusChangeTarget[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: boolean): void
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
const ACTION_CONFIG: Record<
|
||||
StatusChangeAction,
|
||||
{
|
||||
label: string
|
||||
timeLabel: string
|
||||
descLabel: string
|
||||
status: string
|
||||
timeKey: string
|
||||
descKey: string
|
||||
}
|
||||
> = {
|
||||
activate: {
|
||||
label: '协议生效',
|
||||
timeLabel: '协议生效时间',
|
||||
descLabel: '协议生效描述',
|
||||
status: 'sxz',
|
||||
timeKey: 'effectiveTime',
|
||||
descKey: 'effectiveDesc'
|
||||
},
|
||||
expire: {
|
||||
label: '协议过期',
|
||||
timeLabel: '协议过期时间',
|
||||
descLabel: '协议过期描述',
|
||||
status: 'ygq',
|
||||
timeKey: 'expireTime',
|
||||
descKey: 'expireDesc'
|
||||
},
|
||||
void: {
|
||||
label: '协议作废',
|
||||
timeLabel: '协议作废时间',
|
||||
descLabel: '协议作废描述',
|
||||
status: 'yzf',
|
||||
timeKey: 'voidTime',
|
||||
descKey: 'voidDesc'
|
||||
},
|
||||
toResearch: {
|
||||
label: '协议转科研',
|
||||
timeLabel: '协议转科研时间',
|
||||
descLabel: '协议转科研描述',
|
||||
status: 'zky',
|
||||
timeKey: 'toResearchTime',
|
||||
descKey: 'toResearchDesc'
|
||||
}
|
||||
}
|
||||
|
||||
const config = computed(() => ACTION_CONFIG[props.actionType])
|
||||
const actionLabel = computed(() => config.value.label)
|
||||
const timeLabel = computed(() => config.value.timeLabel)
|
||||
const descLabel = computed(() => config.value.descLabel)
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
const label = config.value.label
|
||||
return props.targets.length > 1 ? `批量${label}(${props.targets.length} 条)` : label
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
changeTime: '',
|
||||
changeDesc: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
changeTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.changeTime = ''
|
||||
form.changeDesc = ''
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !props.targets.length) return
|
||||
|
||||
const cfg = config.value
|
||||
const payload: Record<string, unknown> = {
|
||||
protocolStatus: cfg.status,
|
||||
[cfg.timeKey]: form.changeTime,
|
||||
[cfg.descKey]: form.changeDesc.trim() || undefined
|
||||
}
|
||||
const rows = [...props.targets]
|
||||
const isBatch = rows.length > 1
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await withFullscreenLoading(
|
||||
async () => {
|
||||
let ok = 0
|
||||
const failed: string[] = []
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await fetchUpdateCollectionProtocol(String(row.id), payload)
|
||||
ok += 1
|
||||
} catch {
|
||||
failed.push(row.protocolNo || String(row.id))
|
||||
}
|
||||
}
|
||||
if (isBatch) {
|
||||
if (failed.length) {
|
||||
ElMessage.warning(`${cfg.label}成功 ${ok} 条,失败 ${failed.length} 条`)
|
||||
} else {
|
||||
ElMessage.success(`批量${cfg.label}成功,共 ${ok} 条`)
|
||||
}
|
||||
}
|
||||
if (ok > 0) {
|
||||
emit('update:modelValue', false)
|
||||
emit('submitted')
|
||||
}
|
||||
},
|
||||
isBatch ? fullscreenLoadingText.batchAudit : fullscreenLoadingText.save
|
||||
)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-change-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
|
||||
strong {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -228,7 +228,9 @@
|
||||
<el-table-column prop="enterTime" label="进入时间" min-width="160" align="center" header-align="center" />
|
||||
<el-table-column prop="purpose" label="目的" min-width="120" align="center" header-align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="leaveTime" label="离开时间" min-width="160" align="center" header-align="center" />
|
||||
<el-table-column prop="bodyTemp" label="体温(℃)" width="90" align="center" header-align="center" />
|
||||
<el-table-column prop="bodyTemp" label="体温(℃)" width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ row.bodyTemp != null ? row.bodyTemp + '℃' : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleViewLog(row)"
|
||||
@@ -795,7 +797,7 @@
|
||||
{ label: '进入时间', value: row.enterTime },
|
||||
{ label: '离开时间', value: row.leaveTime },
|
||||
{ label: '目的', value: row.purpose, span: true },
|
||||
{ label: '体温(℃)', value: row.bodyTemp ? `${row.bodyTemp}` : '' },
|
||||
{ label: '体温(℃)', value: row.bodyTemp != null ? `${row.bodyTemp}℃` : '' },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
<!-- 质量检测 — 实验室自检项目 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>实验室自检项目</h1>
|
||||
<p class="page-desc">维护本单位实验室可执行的检测项目,供质检工单配置时选择。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">项目列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="编码/名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset"><el-icon><RefreshLeft /></el-icon>重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">已选 <span class="sel-num">{{ selectedRows.length }}</span> 项</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增项目</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="(r: QcLabTestItem[]) => (selectedRows.value = r)"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column type="index" label="序号" width="68" align="center" :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
|
||||
<el-table-column prop="itemCode" label="项目编码" min-width="140" align="center" />
|
||||
<el-table-column prop="itemName" label="项目名称" min-width="160" align="center" />
|
||||
<el-table-column prop="description" label="描述" min-width="200" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status === '1' ? 'success' : 'muted']">{{ row.status === '1' ? '启用' : '停用' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
|
||||
@current-change="(v) => ((pagination.page = v), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" class="dialog-form-layout" align-center destroy-on-close :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="top" class="biz-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="项目编码" prop="itemCode">
|
||||
<el-input v-model="formData.itemCode" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称" prop="itemName">
|
||||
<el-input v-model="formData.itemName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio value="1">启用</el-radio>
|
||||
<el-radio value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" class="form-col-span-2">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<!-- 参数配置 -->
|
||||
<div class="form-section" style="margin-top: 8px">
|
||||
<div class="section-title">参数配置</div>
|
||||
<div class="param-row param-row-header">
|
||||
<span style="flex: 2">参数名称</span>
|
||||
<span style="flex: 1.5">参数编码</span>
|
||||
<span style="flex: 1">单位</span>
|
||||
<span style="flex: 1">类型</span>
|
||||
<span style="width: 40px; text-align: center">必填</span>
|
||||
<span style="width: 32px"></span>
|
||||
</div>
|
||||
<div v-if="itemParams.length === 0" style="text-align: center; padding: 8px 0; color: #94a3b8; font-size: 13px">
|
||||
暂无参数,点击下方按钮添加
|
||||
</div>
|
||||
<div v-for="(param, idx) in itemParams" :key="idx" class="param-row">
|
||||
<el-input v-model="param.paramName" placeholder="参数名称" style="flex: 2" />
|
||||
<el-input v-model="param.paramCode" placeholder="参数编码" style="flex: 1.5" />
|
||||
<el-input v-model="param.paramUnit" placeholder="单位" style="flex: 1" />
|
||||
<el-select v-model="param.paramType" style="flex: 1">
|
||||
<el-option label="文本" value="text" />
|
||||
<el-option label="数字" value="number" />
|
||||
</el-select>
|
||||
<el-switch v-model="param.requiredFlag" active-value="1" inactive-value="0" />
|
||||
<el-button class="btn-op-item" @click="removeParam(idx)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
<el-button type="primary" plain style="margin-top: 8px" @click="addParam"><el-icon><Plus /></el-icon>添加参数</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false"><el-icon><Close /></el-icon>取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit"><el-icon><Check /></el-icon>确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
title="查看项目"
|
||||
:icon="Notebook"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, Search, RefreshLeft, View, Edit, Delete, Notebook } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog, { type ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
import {
|
||||
fetchQcLabTestItemList,
|
||||
fetchCreateQcLabTestItem,
|
||||
fetchUpdateQcLabTestItem,
|
||||
fetchDeleteQcLabTestItem,
|
||||
type QcLabTestItem
|
||||
} from '@/api/qc-lab-test-item'
|
||||
import { fetchQcItemParams, fetchSaveQcItemParams, type QcItemParam } from '@/api/qc-item-param'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionLabTestItem' })
|
||||
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
const tableData = ref<QcLabTestItem[]>([])
|
||||
const selectedRows = ref<QcLabTestItem[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增项目')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const itemParams = ref<QcItemParam[]>([])
|
||||
const formData = reactive({ itemCode: '', itemName: '', description: '', status: '1', remark: '' })
|
||||
const viewVisible = ref(false)
|
||||
const viewSections = ref<ViewSection[]>([])
|
||||
const formRules = {
|
||||
itemCode: [{ required: true, message: '请输入项目编码', trigger: 'blur' }],
|
||||
itemName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function addParam() {
|
||||
itemParams.value.push({
|
||||
paramName: '',
|
||||
paramCode: '',
|
||||
paramUnit: '',
|
||||
paramType: 'text',
|
||||
requiredFlag: '0'
|
||||
})
|
||||
}
|
||||
|
||||
function removeParam(index: number) {
|
||||
itemParams.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchQcLabTestItemList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
status: searchForm.status || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
function resetForm() {
|
||||
Object.assign(formData, { itemCode: '', itemName: '', description: '', status: '1', remark: '' })
|
||||
editingId.value = null
|
||||
itemParams.value = []
|
||||
}
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增项目'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
function handleEdit(row: QcLabTestItem) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑项目'
|
||||
editingId.value = row.id
|
||||
Object.assign(formData, {
|
||||
itemCode: row.itemCode,
|
||||
itemName: row.itemName,
|
||||
description: row.description || '',
|
||||
status: row.status || '1',
|
||||
remark: row.remark || ''
|
||||
})
|
||||
fetchQcItemParams('lab', row.id).then((res) => {
|
||||
itemParams.value = res || []
|
||||
}).catch(() => {
|
||||
itemParams.value = []
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
async function handleSubmit() {
|
||||
if (!(await formRef.value?.validate?.().catch(() => false))) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...formData, remark: formData.remark || undefined, description: formData.description || undefined }
|
||||
let savedId = editingId.value
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateQcLabTestItem(editingId.value, payload)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
const created = await fetchCreateQcLabTestItem(payload)
|
||||
savedId = created?.id || ''
|
||||
ElMessage.success('已新增')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
if (savedId && itemParams.value.length > 0) {
|
||||
await fetchSaveQcItemParams('lab', savedId, itemParams.value).catch(() => {})
|
||||
}
|
||||
loadData()
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
function handleView(row: QcLabTestItem) {
|
||||
viewSections.value = [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '项目编码', value: row.itemCode },
|
||||
{ label: '项目名称', value: row.itemName },
|
||||
{ label: '状态', value: row.status === '1' ? '启用' : '停用' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '详细信息',
|
||||
fields: [
|
||||
{ label: '描述', value: row.description, span: true },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
viewVisible.value = true
|
||||
}
|
||||
async function handleDelete(row: QcLabTestItem) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.itemName}」?`, '提示', { type: 'warning' })
|
||||
await fetchDeleteQcLabTestItem(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
}
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-section {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.form-section .section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.param-row-header {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,382 @@
|
||||
<!-- 质量检测 — 质检任务 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>质检任务</h1>
|
||||
<p class="page-desc">按检测项目跟进任务;实验室自检需开始/完成,第三方需上传检测材料后完成。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">任务列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="任务号/项目/工单号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="任务类型">
|
||||
<el-select v-model="searchForm.taskType" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="实验室自检" value="sysz" />
|
||||
<el-option label="第三方检测" value="dsfjc" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务状态">
|
||||
<el-select v-model="searchForm.taskStatus" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="待执行" value="dzx" />
|
||||
<el-option label="进行中" value="jxz" />
|
||||
<el-option label="已完成" value="ywc" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset"><el-icon><RefreshLeft /></el-icon>重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table class="list-table" :data="tableData" v-loading="loading" stripe @row-dblclick="handleView">
|
||||
<el-table-column type="index" label="序号" width="68" align="center" :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
|
||||
<el-table-column prop="taskNo" label="任务号" min-width="150" align="center" />
|
||||
<el-table-column prop="workOrderNo" label="工单号" min-width="140" align="center" />
|
||||
<el-table-column prop="taskType" label="类型" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.taskType === 'sysz' ? 'success' : 'muted']">{{ taskTypeLabel(row.taskType) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="itemName" label="检测项目" min-width="160" align="center" />
|
||||
<el-table-column prop="companyName" label="第三方公司" min-width="120" align="center">
|
||||
<template #default="{ row }">{{ row.companyName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="taskStatus" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.taskStatus === 'ywc' ? 'success' : row.taskStatus === 'jxz' ? 'warning' : 'muted']">
|
||||
{{ taskStatusLabel(row.taskStatus) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.taskType === 'sysz' && row.taskStatus === 'dzx'">
|
||||
<el-button class="btn-op-item primary" @click="handleStart(row)"><el-icon><VideoPlay /></el-icon>开始</el-button>
|
||||
</template>
|
||||
<template v-if="row.taskType === 'sysz' && row.taskStatus === 'jxz'">
|
||||
<el-button class="btn-op-item primary" @click="openResult(row)"><el-icon><EditPen /></el-icon>填写结果</el-button>
|
||||
</template>
|
||||
<template v-if="row.taskType === 'dsfjc' && row.taskStatus !== 'ywc'">
|
||||
<el-button class="btn-op-item" @click="openAttach(row)"><el-icon><Upload /></el-icon>上传材料</el-button>
|
||||
<el-button class="btn-op-item primary" @click="openResult(row)"><el-icon><EditPen /></el-icon>填写结果</el-button>
|
||||
</template>
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
|
||||
@current-change="(v) => ((pagination.page = v), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="attachVisible" title="上传第三方检测材料" width="720px" class="dialog-form-layout" align-center destroy-on-close :close-on-click-modal="false">
|
||||
<ProtocolFinanceConfirmFields
|
||||
v-model:finance-remark="attachRemark"
|
||||
v-model:attachments="attachments"
|
||||
remark-label="备注"
|
||||
attachment-label="检测材料"
|
||||
upload-folder="qc-third-party"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="attachVisible = false"><el-icon><Close /></el-icon>取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="attachLoading" @click="saveAttachments"><el-icon><Check /></el-icon>保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="resultVisible"
|
||||
title="填写检测结果"
|
||||
width="720px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top" class="biz-dialog-form">
|
||||
<div class="form-section">
|
||||
<div class="section-title">检测参数</div>
|
||||
<div v-if="resultParams.length === 0" style="text-align: center; padding: 8px 0; color: #94a3b8; font-size: 13px">
|
||||
该项目暂无配置参数
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item v-for="(param, idx) in resultParams" :key="idx" :label="param.paramName + (param.paramUnit ? `(${param.paramUnit})` : '')">
|
||||
<el-input v-if="param.paramType === 'text'" v-model="param.paramValue" placeholder="请输入" />
|
||||
<el-input-number v-else v-model="param.paramValue" :precision="2" style="width: 100%" placeholder="请输入" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="resultTaskType === 'dsfjc'" style="margin-top: 12px">
|
||||
<ProtocolFinanceConfirmFields
|
||||
v-model:finance-remark="resultRemark"
|
||||
v-model:attachments="resultAttachments"
|
||||
remark-label="备注"
|
||||
attachment-label="检测材料"
|
||||
upload-folder="qc-third-party"
|
||||
/>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="resultVisible = false"><el-icon><Close /></el-icon>取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="resultLoading" @click="submitResult"><el-icon><Check /></el-icon>提交并完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
title="任务详情"
|
||||
:icon="Tickets"
|
||||
:meta="viewDetail?.taskNo || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Close, Check, Search, RefreshLeft, VideoPlay, Upload, View, Tickets, EditPen } from '@element-plus/icons-vue'
|
||||
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
import {
|
||||
financeAttachmentsToUrlString,
|
||||
type FinanceAttachmentItem
|
||||
} from '@/components/business/protocol-finance-confirm-fields/finance-attachments'
|
||||
import {
|
||||
fetchQcTaskList,
|
||||
fetchQcTaskDetail,
|
||||
fetchStartQcTask,
|
||||
fetchCompleteQcTask,
|
||||
fetchUpdateQcTaskAttachments,
|
||||
type QcTask,
|
||||
type QcTaskDetail
|
||||
} from '@/api/qc-task'
|
||||
import { fetchQcTaskResults, fetchCompleteTaskWithResult, type QcTaskResultItem } from '@/api/qc-task-result'
|
||||
import { taskTypeLabel, taskStatusLabel } from '@/utils/qc-labels'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionQcTask' })
|
||||
|
||||
const searchForm = reactive({ keyword: '', taskType: '', taskStatus: '' })
|
||||
const tableData = ref<QcTask[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const attachVisible = ref(false)
|
||||
const attachLoading = ref(false)
|
||||
const attachTaskId = ref('')
|
||||
const attachRemark = ref('')
|
||||
const attachments = ref<FinanceAttachmentItem[]>([])
|
||||
|
||||
const resultVisible = ref(false)
|
||||
const resultLoading = ref(false)
|
||||
const resultTaskId = ref('')
|
||||
const resultTaskType = ref('')
|
||||
const resultParams = ref<QcTaskResultItem[]>([])
|
||||
const resultRemark = ref('')
|
||||
const resultAttachments = ref<FinanceAttachmentItem[]>([])
|
||||
|
||||
const viewVisible = ref(false)
|
||||
const viewDetail = ref<QcTaskDetail | null>(null)
|
||||
|
||||
const viewSections = computed<ViewSection[]>(() => {
|
||||
const d = viewDetail.value
|
||||
if (!d) return []
|
||||
const resultSection =
|
||||
(d as any).results && (d as any).results.length > 0
|
||||
? [
|
||||
{
|
||||
title: '检测结果',
|
||||
fields: (d as any).results.map((r: any) => ({
|
||||
label: r.paramName + (r.paramUnit ? `(${r.paramUnit})` : ''),
|
||||
value: r.paramValue || '—'
|
||||
}))
|
||||
}
|
||||
]
|
||||
: []
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '任务号', value: d.taskNo },
|
||||
{ label: '工单号', value: d.workOrderNo },
|
||||
{ label: '类型', value: taskTypeLabel(d.taskType) },
|
||||
{ label: '检测项目', value: d.itemName },
|
||||
{ label: '第三方公司', value: d.companyName || '—' },
|
||||
{ label: '状态', value: taskStatusLabel(d.taskStatus) }
|
||||
]
|
||||
},
|
||||
...resultSection
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchQcTaskList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
taskType: searchForm.taskType || undefined,
|
||||
taskStatus: searchForm.taskStatus || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.taskType = ''
|
||||
searchForm.taskStatus = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleStart(row: QcTask) {
|
||||
await fetchStartQcTask(row.id)
|
||||
ElMessage.success('已开始')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleComplete(row: QcTask) {
|
||||
await fetchCompleteQcTask(row.id)
|
||||
ElMessage.success('已完成')
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openAttach(row: QcTask) {
|
||||
attachTaskId.value = row.id
|
||||
attachRemark.value = ''
|
||||
attachments.value = []
|
||||
attachVisible.value = true
|
||||
}
|
||||
|
||||
async function saveAttachments() {
|
||||
if (attachments.value.some((a) => a._uploading)) {
|
||||
ElMessage.warning('附件正在上传,请稍候')
|
||||
return
|
||||
}
|
||||
attachLoading.value = true
|
||||
try {
|
||||
await fetchUpdateQcTaskAttachments(
|
||||
attachTaskId.value,
|
||||
financeAttachmentsToUrlString(attachments.value)
|
||||
)
|
||||
ElMessage.success('已保存')
|
||||
attachVisible.value = false
|
||||
loadData()
|
||||
} finally {
|
||||
attachLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleView(row: QcTask) {
|
||||
const detail = await fetchQcTaskDetail(row.id)
|
||||
try {
|
||||
const results = await fetchQcTaskResults(row.id)
|
||||
;(detail as any).results = results || []
|
||||
} catch {
|
||||
;(detail as any).results = []
|
||||
}
|
||||
viewDetail.value = detail
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
async function openResult(row: QcTask) {
|
||||
resultTaskId.value = row.id
|
||||
resultTaskType.value = row.taskType
|
||||
resultRemark.value = ''
|
||||
resultAttachments.value = []
|
||||
resultLoading.value = true
|
||||
try {
|
||||
const results = await fetchQcTaskResults(row.id)
|
||||
if (results && results.length > 0) {
|
||||
resultParams.value = results
|
||||
} else {
|
||||
resultParams.value = []
|
||||
}
|
||||
} catch {
|
||||
resultParams.value = []
|
||||
} finally {
|
||||
resultLoading.value = false
|
||||
}
|
||||
resultVisible.value = true
|
||||
}
|
||||
|
||||
async function submitResult() {
|
||||
if (resultAttachments.value.some((a) => a._uploading)) {
|
||||
ElMessage.warning('附件正在上传,请稍候')
|
||||
return
|
||||
}
|
||||
resultLoading.value = true
|
||||
try {
|
||||
const data: { results: QcTaskResultItem[]; thirdPartyAttachments?: string } = {
|
||||
results: resultParams.value.filter((p) => p.paramValue !== '' && p.paramValue !== undefined)
|
||||
}
|
||||
if (resultTaskType.value === 'dsfjc') {
|
||||
data.thirdPartyAttachments = financeAttachmentsToUrlString(resultAttachments.value)
|
||||
}
|
||||
await fetchCompleteTaskWithResult(resultTaskId.value, data)
|
||||
ElMessage.success('已完成')
|
||||
resultVisible.value = false
|
||||
loadData()
|
||||
} finally {
|
||||
resultLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-section {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-section .section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.biz-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,305 @@
|
||||
<!-- 质量检测 — 质检工单 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>质检工单</h1>
|
||||
<p class="page-desc">样本接收(结论为接收)后自动生成工单;在此配置检测方式与具体项目。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">工单列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="工单号/协议号/样本编码" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="工单状态">
|
||||
<el-select v-model="searchForm.workOrderStatus" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="待配置" value="dpz" />
|
||||
<el-option label="已配置" value="ypz" />
|
||||
<el-option label="已完成" value="ywc" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset"><el-icon><RefreshLeft /></el-icon>重置</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table class="list-table" :data="tableData" v-loading="loading" stripe @row-dblclick="handleView">
|
||||
<el-table-column type="index" label="序号" width="68" align="center" :index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1" />
|
||||
<el-table-column prop="workOrderNo" label="工单号" min-width="150" align="center" />
|
||||
<el-table-column prop="protocolNo" label="协议号" min-width="140" align="center" />
|
||||
<el-table-column prop="sampleCode" label="样本编码" min-width="120" align="center" />
|
||||
<el-table-column prop="workOrderStatus" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.workOrderStatus === 'ywc' ? 'success' : row.workOrderStatus === 'ypz' ? 'warning' : 'muted']">
|
||||
{{ workOrderStatusLabel(row.workOrderStatus) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测方式" min-width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.hasLab" class="tag success">实验室</span>
|
||||
<span v-if="row.hasThirdParty" class="tag muted" style="margin-left: 4px">第三方</span>
|
||||
<span v-if="!row.hasLab && !row.hasThirdParty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" />
|
||||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button v-if="row.workOrderStatus === 'dpz'" class="btn-op-item primary" @click="handleConfigure(row)"><el-icon><Setting /></el-icon>配置检测</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
|
||||
@current-change="(v) => ((pagination.page = v), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="configureVisible" title="配置检测" width="800px" class="dialog-form-layout" align-center destroy-on-close :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="biz-dialog-form">
|
||||
<el-form-item label="检测方式">
|
||||
<el-checkbox v-model="configureForm.hasLab">实验室自检</el-checkbox>
|
||||
<el-checkbox v-model="configureForm.hasThirdParty" style="margin-left: 16px">第三方检测</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="configureForm.hasLab" label="实验室自检项目">
|
||||
<el-checkbox-group v-model="configureForm.labItemIds" class="qc-config-check-group">
|
||||
<el-checkbox v-for="item in labOptions" :key="item.id" :value="item.id">{{ item.itemName }}({{ item.itemCode }})</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<p v-if="!labOptions.length" class="hint-muted">暂无启用的实验室项目</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="configureForm.hasThirdParty" label="第三方检测项目">
|
||||
<el-checkbox-group v-model="configureForm.thirdPartyItemIds" class="qc-config-check-group">
|
||||
<div v-for="group in thirdOptionsGrouped" :key="group.companyId" class="qc-company-group">
|
||||
<div class="qc-company-group-title">{{ group.companyName }}</div>
|
||||
<div class="qc-company-group-items">
|
||||
<el-checkbox v-for="item in group.items" :key="item.id" :value="item.id">
|
||||
{{ item.itemName }}({{ item.itemCode }})¥{{ item.feeAmount }}
|
||||
</el-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
<p v-if="!thirdOptions.length" class="hint-muted">暂无启用的第三方项目</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="configureVisible = false"><el-icon><Close /></el-icon>取消</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="configureLoading" @click="submitConfigure"><el-icon><Check /></el-icon>确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
title="工单详情"
|
||||
:icon="Document"
|
||||
:meta="detail?.workOrderNo || ''"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Close, Check, Search, RefreshLeft, View, Setting, Document } from '@element-plus/icons-vue'
|
||||
import { fetchQcLabTestItemsActive, type QcLabTestItem } from '@/api/qc-lab-test-item'
|
||||
import { fetchQcThirdPartyItemsActive, type QcThirdPartyItem } from '@/api/qc-third-party-item'
|
||||
import {
|
||||
fetchQcWorkOrderList,
|
||||
fetchQcWorkOrderDetail,
|
||||
fetchConfigureQcWorkOrder,
|
||||
type QcWorkOrder,
|
||||
type QcWorkOrderDetail
|
||||
} from '@/api/qc-work-order'
|
||||
import { workOrderStatusLabel } from '@/utils/qc-labels'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionQcWorkOrder' })
|
||||
|
||||
const searchForm = reactive({ keyword: '', workOrderStatus: '' })
|
||||
const tableData = ref<QcWorkOrder[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const configureVisible = ref(false)
|
||||
const configureLoading = ref(false)
|
||||
const configuringId = ref('')
|
||||
const labOptions = ref<QcLabTestItem[]>([])
|
||||
const thirdOptions = ref<QcThirdPartyItem[]>([])
|
||||
const thirdOptionsGrouped = computed(() => {
|
||||
const map = new Map<string, { companyId: string; companyName: string; items: QcThirdPartyItem[] }>()
|
||||
for (const item of thirdOptions.value) {
|
||||
const cid = item.companyId
|
||||
if (!map.has(cid)) {
|
||||
map.set(cid, { companyId: cid, companyName: item.companyName || '', items: [] })
|
||||
}
|
||||
map.get(cid)!.items.push(item)
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
const configureForm = reactive({
|
||||
hasLab: false,
|
||||
hasThirdParty: false,
|
||||
labItemIds: [] as string[],
|
||||
thirdPartyItemIds: [] as string[]
|
||||
})
|
||||
|
||||
const viewVisible = ref(false)
|
||||
const detail = ref<QcWorkOrderDetail | null>(null)
|
||||
|
||||
const viewSections = computed<ViewSection[]>(() => {
|
||||
if (!detail.value) return []
|
||||
const d = detail.value
|
||||
return [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '工单号', value: d.workOrderNo },
|
||||
{ label: '协议号', value: d.protocolNo },
|
||||
{ label: '样本编码', value: d.sampleCode },
|
||||
{ label: '状态', value: workOrderStatusLabel(d.workOrderStatus) }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '实验室项目',
|
||||
fields: [
|
||||
{
|
||||
label: '检测项目',
|
||||
value: d.labItems?.map((li) => li.itemName + '(' + li.itemCode + ')').join('、') || '无',
|
||||
span: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '第三方项目',
|
||||
fields: [
|
||||
{
|
||||
label: '检测项目',
|
||||
value:
|
||||
d.thirdPartyItems?.map((li) => li.companyName + ' — ' + li.itemName + '(¥' + li.feeAmount + ')').join('、') ||
|
||||
'无',
|
||||
span: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchQcWorkOrderList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
workOrderStatus: searchForm.workOrderStatus || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.workOrderStatus = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleConfigure(row: QcWorkOrder) {
|
||||
configuringId.value = row.id
|
||||
configureForm.hasLab = false
|
||||
configureForm.hasThirdParty = false
|
||||
configureForm.labItemIds = []
|
||||
configureForm.thirdPartyItemIds = []
|
||||
labOptions.value = (await fetchQcLabTestItemsActive()) || []
|
||||
thirdOptions.value = (await fetchQcThirdPartyItemsActive()) || []
|
||||
configureVisible.value = true
|
||||
}
|
||||
|
||||
async function submitConfigure() {
|
||||
if (!configureForm.hasLab && !configureForm.hasThirdParty) {
|
||||
ElMessage.warning('请至少选择一种检测方式')
|
||||
return
|
||||
}
|
||||
configureLoading.value = true
|
||||
try {
|
||||
await fetchConfigureQcWorkOrder(configuringId.value, { ...configureForm })
|
||||
ElMessage.success('配置成功,已生成质检任务')
|
||||
configureVisible.value = false
|
||||
loadData()
|
||||
} finally {
|
||||
configureLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleView(row: QcWorkOrder) {
|
||||
detail.value = await fetchQcWorkOrderDetail(row.id)
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.qc-config-check-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.qc-company-group {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.qc-company-group-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #334155;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px dashed #e2e8f0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.qc-company-group-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.hint-muted {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<!-- 质量检测 — 样本接收检查项配置 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>接收检查项配置</h1>
|
||||
<p class="page-desc">维护样本接收登记时的检查项;仅「启用」项会出现在样本接收界面。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">接收检查项列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="编码/名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<span class="filter-hint">停用后新登记不再展示,历史接收记录仍按快照展示。</span>
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增检查项
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
type="danger"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="itemCode" label="检查项编码" min-width="140" align="center" header-align="center" />
|
||||
<el-table-column prop="itemName" label="检查项名称" min-width="160" align="center" header-align="center" />
|
||||
<el-table-column prop="orderNum" label="排序" width="80" align="center" header-align="center" />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status === '1' ? 'success' : 'muted']">
|
||||
{{ row.status === '1' ? '启用' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="170" align="center" header-align="center" />
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-position="top"
|
||||
class="biz-dialog-form"
|
||||
scroll-to-error
|
||||
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
|
||||
>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="检查项编码" prop="itemCode">
|
||||
<el-input v-model="formData.itemCode" placeholder="租户内唯一,如 packaging" :disabled="isEdit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="检查项名称" prop="itemName">
|
||||
<el-input v-model="formData.itemName" placeholder="如:包装" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="formData.orderNum" :min="0" :max="9999" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio value="1">启用</el-radio>
|
||||
<el-radio value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchSampleReceptionCheckItemList,
|
||||
fetchCreateSampleReceptionCheckItem,
|
||||
fetchUpdateSampleReceptionCheckItem,
|
||||
fetchDeleteSampleReceptionCheckItem,
|
||||
type SampleReceptionCheckItem
|
||||
} from '@/api/sample-reception-check-item'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionSampleReceptionCheckItem' })
|
||||
|
||||
const searchForm = reactive({ keyword: '', status: '' as string })
|
||||
const tableData = ref<SampleReceptionCheckItem[]>([])
|
||||
const selectedRows = ref<SampleReceptionCheckItem[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增检查项')
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
itemCode: '',
|
||||
itemName: '',
|
||||
orderNum: 0,
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
itemCode: [{ required: true, message: '请输入检查项编码', trigger: 'blur' }],
|
||||
itemName: [{ required: true, message: '请输入检查项名称', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows: SampleReceptionCheckItem[]) => {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchSampleReceptionCheckItemList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
status: searchForm.status || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.itemCode = ''
|
||||
formData.itemName = ''
|
||||
formData.orderNum = 0
|
||||
formData.status = '1'
|
||||
formData.remark = ''
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增检查项'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(row: SampleReceptionCheckItem) {
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑检查项'
|
||||
editingId.value = row.id
|
||||
formData.itemCode = row.itemCode
|
||||
formData.itemName = row.itemName
|
||||
formData.orderNum = row.orderNum ?? 0
|
||||
formData.status = row.status || '1'
|
||||
formData.remark = row.remark || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate?.().catch(() => false)
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
itemCode: formData.itemCode,
|
||||
itemName: formData.itemName,
|
||||
orderNum: formData.orderNum,
|
||||
status: formData.status,
|
||||
remark: formData.remark || undefined
|
||||
}
|
||||
if (isEdit.value && editingId.value) {
|
||||
await fetchUpdateSampleReceptionCheckItem(editingId.value, payload)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await fetchCreateSampleReceptionCheckItem(payload)
|
||||
ElMessage.success('已新增')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: SampleReceptionCheckItem) {
|
||||
await ElMessageBox.confirm(`确定删除检查项「${row.itemName}」?`, '提示', { type: 'warning' })
|
||||
await fetchDeleteSampleReceptionCheckItem(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!selectedRows.value.length) return
|
||||
await ElMessageBox.confirm(`确定删除选中的 ${selectedRows.value.length} 项?`, '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
for (const row of selectedRows.value) {
|
||||
await fetchDeleteSampleReceptionCheckItem(row.id)
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
selectedRows.value = []
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,723 @@
|
||||
<!-- 质量检测 — 第三方检测公司及项目 -->
|
||||
<!-- eslint-disable vue/no-v-model-argument -->
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<header class="page-head">
|
||||
<h1>第三方检测公司及项目</h1>
|
||||
<p class="page-desc">维护外包检测公司信息,并在各公司下配置检测项目及费用。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-top">
|
||||
<h2 class="title-bar-heading">公司列表</h2>
|
||||
</div>
|
||||
|
||||
<div class="filter-block">
|
||||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="searchForm.keyword" placeholder="编码/名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 100%">
|
||||
<el-option label="启用" value="1" />
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="filter-actions-row">
|
||||
<div class="filter-actions-end">
|
||||
<el-button class="btn-ghost" @click="handleReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增公司
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-body list-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="tableData"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
row-key="id"
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
width="68"
|
||||
align="center"
|
||||
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
|
||||
/>
|
||||
<el-table-column prop="companyCode" label="公司编码" min-width="120" align="center" />
|
||||
<el-table-column prop="companyName" label="公司名称" min-width="160" align="center" />
|
||||
<el-table-column prop="contactName" label="联系人" min-width="100" align="center" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" min-width="120" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status === '1' ? 'success' : 'muted']">
|
||||
{{ row.status === '1' ? '启用' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="400" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item" @click="handleView(row)"><el-icon><View /></el-icon>查看</el-button>
|
||||
<el-button class="btn-op-item primary" @click="openItemDialog(row)"><el-icon><Setting /></el-icon>检测项目配置</el-button>
|
||||
<el-button class="btn-op-item" @click="handleEdit(row)"><el-icon><Edit /></el-icon>编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDelete(row)"><el-icon><Delete /></el-icon>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(v) => ((pagination.page = 1), (pagination.pageSize = v), loadData())"
|
||||
@current-change="(v) => ((pagination.page = v), loadData())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="companyDialogVisible"
|
||||
:title="companyDialogTitle"
|
||||
width="720px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="companyFormRef" :model="companyForm" :rules="companyFormRules" label-position="top" class="biz-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="公司编码" prop="companyCode">
|
||||
<el-input v-model="companyForm.companyCode" :disabled="companyIsEdit" placeholder="租户内唯一" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司名称" prop="companyName">
|
||||
<el-input v-model="companyForm.companyName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="companyForm.contactName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="companyForm.contactPhone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址" class="form-col-span-2">
|
||||
<el-input v-model="companyForm.address" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<div class="section-title">开票信息</div>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="发票抬头">
|
||||
<el-input v-model="companyForm.invoiceTitle" placeholder="与营业执照一致" />
|
||||
</el-form-item>
|
||||
<el-form-item label="纳税人识别号">
|
||||
<el-input v-model="companyForm.taxId" placeholder="统一社会信用代码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户银行">
|
||||
<el-input v-model="companyForm.bankName" placeholder="收款银行全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="companyForm.bankAccount" placeholder="收款账号" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="companyForm.status">
|
||||
<el-radio value="1">启用</el-radio>
|
||||
<el-radio value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="companyForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="companyDialogVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="companySubmitLoading" @click="submitCompany">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="itemDialogVisible"
|
||||
:title="itemDialogTitle"
|
||||
width="880px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
@closed="onItemDialogClosed"
|
||||
>
|
||||
<div class="item-dialog-head">
|
||||
<span class="muted">当前公司:{{ currentCompanyName }}</span>
|
||||
</div>
|
||||
<div class="list-toolbar item-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ itemSelected.length }}</span> 项
|
||||
</div>
|
||||
<el-button class="btn-main" type="primary" @click="handleAddItem">
|
||||
<el-icon><Plus /></el-icon>新增项目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-body list-wrap item-table-wrap">
|
||||
<el-table
|
||||
class="list-table"
|
||||
:data="itemTable"
|
||||
v-loading="itemLoading"
|
||||
stripe
|
||||
row-key="id"
|
||||
max-height="360"
|
||||
@selection-change="onItemSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column type="index" label="序号" width="68" align="center" />
|
||||
<el-table-column prop="itemCode" label="项目编码" min-width="120" align="center" />
|
||||
<el-table-column prop="itemName" label="项目名称" min-width="160" align="center" />
|
||||
<el-table-column prop="feeAmount" label="费用(元)" width="110" align="center">
|
||||
<template #default="{ row }">{{ row.feeAmount != null ? `¥${row.feeAmount}` : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="['tag', row.status === '1' ? 'success' : 'muted']">
|
||||
{{ row.status === '1' ? '启用' : '停用' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button class="btn-op-item primary" @click="handleEditItem(row)"><el-icon><Edit /></el-icon>编辑</el-button>
|
||||
<el-button class="btn-op-item" @click="handleDeleteItem(row)"><el-icon><Delete /></el-icon>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination list-pager-bar">
|
||||
<el-pagination
|
||||
:current-page="itemPagination.page"
|
||||
:page-size="itemPagination.pageSize"
|
||||
:total="itemPagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="(v) => ((itemPagination.page = 1), (itemPagination.pageSize = v), loadItems())"
|
||||
@current-change="(v) => ((itemPagination.page = v), loadItems())"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="itemFormVisible"
|
||||
:title="itemFormTitle"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="itemFormRef" :model="itemForm" :rules="itemFormRules" label-position="top" class="biz-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="项目编码" prop="itemCode">
|
||||
<el-input v-model="itemForm.itemCode" :disabled="itemIsEdit" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称" prop="itemName">
|
||||
<el-input v-model="itemForm.itemName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用(元)" prop="feeAmount">
|
||||
<el-input-number v-model="itemForm.feeAmount" :min="0" :precision="2" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="itemForm.status">
|
||||
<el-radio value="1">启用</el-radio>
|
||||
<el-radio value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" class="form-col-span-2">
|
||||
<el-input v-model="itemForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="form-section" style="margin-top: 8px">
|
||||
<div class="section-title">参数配置</div>
|
||||
<div class="param-row param-row-header">
|
||||
<span style="flex: 2">参数名称</span>
|
||||
<span style="flex: 1.5">参数编码</span>
|
||||
<span style="flex: 1">单位</span>
|
||||
<span style="flex: 1">类型</span>
|
||||
<span style="width: 40px; text-align: center">必填</span>
|
||||
<span style="width: 32px"></span>
|
||||
</div>
|
||||
<div v-if="itemParams.length === 0" style="text-align: center; padding: 8px 0; color: #94a3b8; font-size: 13px">
|
||||
暂无参数,点击下方按钮添加
|
||||
</div>
|
||||
<div v-for="(param, idx) in itemParams" :key="idx" class="param-row">
|
||||
<el-input v-model="param.paramName" placeholder="参数名称" style="flex: 2" />
|
||||
<el-input v-model="param.paramCode" placeholder="参数编码" style="flex: 1.5" />
|
||||
<el-input v-model="param.paramUnit" placeholder="单位" style="flex: 1" />
|
||||
<el-select v-model="param.paramType" style="flex: 1">
|
||||
<el-option label="文本" value="text" />
|
||||
<el-option label="数字" value="number" />
|
||||
</el-select>
|
||||
<el-switch v-model="param.requiredFlag" active-value="1" inactive-value="0" />
|
||||
<el-button class="btn-op-item" @click="removeItemParam(idx)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
<el-button type="primary" plain style="margin-top: 8px" @click="addItemParam"><el-icon><Plus /></el-icon>添加参数</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="itemFormVisible = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="itemSubmitLoading" @click="submitItem">
|
||||
<el-icon><Check /></el-icon>确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="itemDialogVisible = false">
|
||||
<el-icon><Close /></el-icon>关闭
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<SystemViewDialog
|
||||
v-model:visible="viewVisible"
|
||||
title="查看公司"
|
||||
:icon="OfficeBuilding"
|
||||
:meta="viewCompanyName"
|
||||
:sections="viewSections"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Close, Check, Search, RefreshLeft, View, Setting, Edit, Delete, OfficeBuilding } from '@element-plus/icons-vue'
|
||||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||||
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
|
||||
import {
|
||||
fetchQcThirdPartyCompanyList,
|
||||
fetchCreateQcThirdPartyCompany,
|
||||
fetchUpdateQcThirdPartyCompany,
|
||||
fetchDeleteQcThirdPartyCompany,
|
||||
type QcThirdPartyCompany
|
||||
} from '@/api/qc-third-party-company'
|
||||
import {
|
||||
fetchQcThirdPartyItemList,
|
||||
fetchCreateQcThirdPartyItem,
|
||||
fetchUpdateQcThirdPartyItem,
|
||||
fetchDeleteQcThirdPartyItem,
|
||||
type QcThirdPartyItem
|
||||
} from '@/api/qc-third-party-item'
|
||||
import { fetchQcItemParams, fetchSaveQcItemParams, type QcItemParam } from '@/api/qc-item-param'
|
||||
|
||||
defineOptions({ name: 'QualityInspectionThirdPartyCompany' })
|
||||
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
const tableData = ref<QcThirdPartyCompany[]>([])
|
||||
const selectedRows = ref<QcThirdPartyCompany[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const companyDialogVisible = ref(false)
|
||||
const companyDialogTitle = ref('新增公司')
|
||||
const companyIsEdit = ref(false)
|
||||
const companySubmitLoading = ref(false)
|
||||
const companyFormRef = ref()
|
||||
const editingCompanyId = ref<string | null>(null)
|
||||
const companyForm = reactive({
|
||||
companyCode: '',
|
||||
companyName: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
invoiceTitle: '',
|
||||
taxId: '',
|
||||
bankName: '',
|
||||
bankAccount: '',
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
const companyFormRules = {
|
||||
companyCode: [{ required: true, message: '请输入公司编码', trigger: 'blur' }],
|
||||
companyName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const itemDialogVisible = ref(false)
|
||||
const itemDialogTitle = ref('检测项目配置')
|
||||
const currentCompanyId = ref('')
|
||||
const currentCompanyName = ref('')
|
||||
const itemTable = ref<QcThirdPartyItem[]>([])
|
||||
const itemSelected = ref<QcThirdPartyItem[]>([])
|
||||
const itemLoading = ref(false)
|
||||
const itemPagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const itemFormVisible = ref(false)
|
||||
const itemFormTitle = ref('新增项目')
|
||||
const itemIsEdit = ref(false)
|
||||
const itemSubmitLoading = ref(false)
|
||||
const itemFormRef = ref()
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const itemParams = ref<QcItemParam[]>([])
|
||||
const itemForm = reactive({
|
||||
itemCode: '',
|
||||
itemName: '',
|
||||
feeAmount: 0,
|
||||
status: '1',
|
||||
remark: ''
|
||||
})
|
||||
const itemFormRules = {
|
||||
itemCode: [{ required: true, message: '请输入项目编码', trigger: 'blur' }],
|
||||
itemName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
|
||||
feeAmount: [{ required: true, message: '请输入费用', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: QcThirdPartyCompany[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
function onItemSelectionChange(rows: QcThirdPartyItem[]) {
|
||||
itemSelected.value = rows
|
||||
}
|
||||
|
||||
function addItemParam() {
|
||||
itemParams.value.push({
|
||||
paramName: '',
|
||||
paramCode: '',
|
||||
paramUnit: '',
|
||||
paramType: 'text',
|
||||
requiredFlag: '0'
|
||||
})
|
||||
}
|
||||
|
||||
function removeItemParam(index: number) {
|
||||
itemParams.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchQcThirdPartyCompanyList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
status: searchForm.status || undefined
|
||||
})
|
||||
tableData.value = res.list || []
|
||||
pagination.total = res.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.status = ''
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetCompanyForm() {
|
||||
companyForm.companyCode = ''
|
||||
companyForm.companyName = ''
|
||||
companyForm.contactName = ''
|
||||
companyForm.contactPhone = ''
|
||||
companyForm.address = ''
|
||||
companyForm.invoiceTitle = ''
|
||||
companyForm.taxId = ''
|
||||
companyForm.bankName = ''
|
||||
companyForm.bankAccount = ''
|
||||
companyForm.status = '1'
|
||||
companyForm.remark = ''
|
||||
editingCompanyId.value = null
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
companyIsEdit.value = false
|
||||
companyDialogTitle.value = '新增公司'
|
||||
resetCompanyForm()
|
||||
companyDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(row: QcThirdPartyCompany) {
|
||||
companyIsEdit.value = true
|
||||
companyDialogTitle.value = '编辑公司'
|
||||
editingCompanyId.value = row.id
|
||||
companyForm.companyCode = row.companyCode
|
||||
companyForm.companyName = row.companyName
|
||||
companyForm.contactName = row.contactName || ''
|
||||
companyForm.contactPhone = row.contactPhone || ''
|
||||
companyForm.address = row.address || ''
|
||||
companyForm.invoiceTitle = row.invoiceTitle || ''
|
||||
companyForm.taxId = row.taxId || ''
|
||||
companyForm.bankName = row.bankName || ''
|
||||
companyForm.bankAccount = row.bankAccount || ''
|
||||
companyForm.status = row.status || '1'
|
||||
companyForm.remark = row.remark || ''
|
||||
companyDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitCompany() {
|
||||
const valid = await companyFormRef.value?.validate?.().catch(() => false)
|
||||
if (!valid) return
|
||||
companySubmitLoading.value = true
|
||||
try {
|
||||
const payload = { ...companyForm, remark: companyForm.remark || undefined }
|
||||
if (companyIsEdit.value && editingCompanyId.value) {
|
||||
await fetchUpdateQcThirdPartyCompany(editingCompanyId.value, payload)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await fetchCreateQcThirdPartyCompany(payload)
|
||||
ElMessage.success('已新增')
|
||||
}
|
||||
companyDialogVisible.value = false
|
||||
loadData()
|
||||
} finally {
|
||||
companySubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: QcThirdPartyCompany) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.companyName}」?其下属检测项目将一并删除。`, '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
await fetchDeleteQcThirdPartyCompany(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
}
|
||||
|
||||
const viewVisible = ref(false)
|
||||
const viewCompanyName = ref('')
|
||||
const viewSections = ref<ViewSection[]>([])
|
||||
|
||||
function handleView(row: QcThirdPartyCompany) {
|
||||
viewCompanyName.value = row.companyName || ''
|
||||
viewSections.value = [
|
||||
{
|
||||
title: '基本信息',
|
||||
fields: [
|
||||
{ label: '公司编码', value: row.companyCode },
|
||||
{ label: '公司名称', value: row.companyName },
|
||||
{ label: '联系人', value: row.contactName },
|
||||
{ label: '联系电话', value: row.contactPhone },
|
||||
{ label: '地址', value: row.address, span: true },
|
||||
{ label: '状态', value: row.status === '1' ? '启用' : '停用' },
|
||||
{ label: '备注', value: row.remark, span: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '开票信息',
|
||||
fields: [
|
||||
{ label: '发票抬头', value: row.invoiceTitle },
|
||||
{ label: '纳税人识别号', value: row.taxId },
|
||||
{ label: '开户银行', value: row.bankName },
|
||||
{ label: '银行账号', value: row.bankAccount }
|
||||
]
|
||||
}
|
||||
]
|
||||
viewVisible.value = true
|
||||
}
|
||||
|
||||
function openItemDialog(row: QcThirdPartyCompany) {
|
||||
currentCompanyId.value = row.id
|
||||
currentCompanyName.value = row.companyName || ''
|
||||
itemPagination.page = 1
|
||||
itemSelected.value = []
|
||||
itemDialogTitle.value = `检测项目配置 — ${row.companyName}`
|
||||
itemDialogVisible.value = true
|
||||
loadItems()
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
if (!currentCompanyId.value) return
|
||||
itemLoading.value = true
|
||||
try {
|
||||
const res = await fetchQcThirdPartyItemList({
|
||||
page: itemPagination.page,
|
||||
pageSize: itemPagination.pageSize,
|
||||
companyId: currentCompanyId.value
|
||||
})
|
||||
itemTable.value = res.list || []
|
||||
itemPagination.total = res.total ?? 0
|
||||
} finally {
|
||||
itemLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onItemDialogClosed() {
|
||||
currentCompanyId.value = ''
|
||||
currentCompanyName.value = ''
|
||||
itemTable.value = []
|
||||
}
|
||||
|
||||
function resetItemForm() {
|
||||
itemForm.itemCode = ''
|
||||
itemForm.itemName = ''
|
||||
itemForm.feeAmount = 0
|
||||
itemForm.status = '1'
|
||||
itemForm.remark = ''
|
||||
editingItemId.value = null
|
||||
itemParams.value = []
|
||||
}
|
||||
|
||||
function handleAddItem() {
|
||||
itemIsEdit.value = false
|
||||
itemFormTitle.value = '新增项目'
|
||||
resetItemForm()
|
||||
itemParams.value = []
|
||||
itemFormVisible.value = true
|
||||
}
|
||||
|
||||
function handleEditItem(row: QcThirdPartyItem) {
|
||||
itemIsEdit.value = true
|
||||
itemFormTitle.value = '编辑项目'
|
||||
editingItemId.value = row.id
|
||||
itemForm.itemCode = row.itemCode
|
||||
itemForm.itemName = row.itemName
|
||||
itemForm.feeAmount = row.feeAmount ?? 0
|
||||
itemForm.status = row.status || '1'
|
||||
itemForm.remark = row.remark || ''
|
||||
fetchQcItemParams('third_party', row.id).then((res) => {
|
||||
itemParams.value = res || []
|
||||
}).catch(() => {
|
||||
itemParams.value = []
|
||||
})
|
||||
itemFormVisible.value = true
|
||||
}
|
||||
|
||||
async function submitItem() {
|
||||
const valid = await itemFormRef.value?.validate?.().catch(() => false)
|
||||
if (!valid) return
|
||||
itemSubmitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
companyId: currentCompanyId.value,
|
||||
itemCode: itemForm.itemCode,
|
||||
itemName: itemForm.itemName,
|
||||
feeAmount: itemForm.feeAmount,
|
||||
status: itemForm.status,
|
||||
remark: itemForm.remark || undefined
|
||||
}
|
||||
let savedId = editingItemId.value
|
||||
if (itemIsEdit.value && editingItemId.value) {
|
||||
await fetchUpdateQcThirdPartyItem(editingItemId.value, payload)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
const created = await fetchCreateQcThirdPartyItem(payload)
|
||||
savedId = created?.id || ''
|
||||
ElMessage.success('已新增')
|
||||
}
|
||||
if (savedId && itemParams.value.length > 0) {
|
||||
await fetchSaveQcItemParams('third_party', savedId, itemParams.value).catch(() => {})
|
||||
}
|
||||
itemFormVisible.value = false
|
||||
loadItems()
|
||||
} finally {
|
||||
itemSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(row: QcThirdPartyItem) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.itemName}」?`, '提示', { type: 'warning' })
|
||||
await fetchDeleteQcThirdPartyItem(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.biz-dialog-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-dialog-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.item-dialog-head .muted {
|
||||
font-size: 13px;
|
||||
color: var(--mint-text-muted);
|
||||
}
|
||||
|
||||
.item-toolbar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.item-table-wrap {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.form-section .section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.param-row-header {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -179,6 +179,11 @@
|
||||
<el-option label="停用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" style="grid-column: 1 / -1">
|
||||
<el-select v-model="formData.roleIds" multiple placeholder="请选择角色" style="width: 100%">
|
||||
<el-option v-for="item in roleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" style="grid-column: 1 / -1">
|
||||
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
@@ -218,9 +223,20 @@
|
||||
fetchUpdateUser,
|
||||
fetchDeleteUser,
|
||||
fetchResetPassword,
|
||||
fetchDeptTree
|
||||
fetchDeptTree,
|
||||
fetchRoleOptions
|
||||
} from '@/api/admin'
|
||||
|
||||
const roleOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
async function loadRoleOptions() {
|
||||
try {
|
||||
roleOptions.value = (await fetchRoleOptions()) || []
|
||||
} catch {
|
||||
roleOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
defineOptions({ name: 'SystemUser' })
|
||||
|
||||
const deptKeyword = ref('')
|
||||
@@ -251,7 +267,8 @@
|
||||
avatar: '',
|
||||
status: '1',
|
||||
orderNum: 0,
|
||||
remark: ''
|
||||
remark: '',
|
||||
roleIds: [] as string[]
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -327,6 +344,7 @@
|
||||
formData.status = '1'
|
||||
formData.orderNum = 0
|
||||
formData.remark = ''
|
||||
formData.roleIds = []
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
@@ -356,6 +374,7 @@
|
||||
formData.status = row.status || '1'
|
||||
formData.orderNum = row.orderNum ?? 0
|
||||
formData.remark = row.remark || ''
|
||||
formData.roleIds = (row as any).roleIds || []
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate?.()
|
||||
}
|
||||
@@ -373,7 +392,8 @@
|
||||
sex: formData.sex,
|
||||
status: formData.status,
|
||||
orderNum: formData.orderNum,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
remark: formData.remark?.trim() || undefined,
|
||||
roleIds: formData.roleIds.length ? formData.roleIds : undefined
|
||||
})
|
||||
} else {
|
||||
await fetchCreateUser({
|
||||
@@ -386,7 +406,8 @@
|
||||
sex: formData.sex,
|
||||
status: formData.status,
|
||||
orderNum: formData.orderNum,
|
||||
remark: formData.remark?.trim() || undefined
|
||||
remark: formData.remark?.trim() || undefined,
|
||||
roleIds: formData.roleIds.length ? formData.roleIds : undefined
|
||||
})
|
||||
}
|
||||
dialogVisible.value = false
|
||||
@@ -445,6 +466,7 @@
|
||||
onMounted(() => {
|
||||
loadDeptTree()
|
||||
loadData()
|
||||
loadRoleOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in new issue
Block a user