1135 lines
38 KiB
Markdown
1135 lines
38 KiB
Markdown
# 样本入库信息短信通知 — 实施计划
|
||
|
||
> **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:** 克隆样本接收信息的完整链路(前端页面 + 后端 API + 独立通知表),实现样本入库信息的短信通知管理。
|
||
|
||
**Architecture:** 新建独立表 `biz_storage_entry_sms_notification`,关联 `biz_storage_release_review`;后端 CRUD 克隆 `sample_sms_notification.py` 的联查+懒创建+短信生成模式;前端克隆 `sample-reception-info/index.vue` 页面。
|
||
|
||
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic (后端) / Vue 3 + Element Plus + TypeScript (前端)
|
||
|
||
**Design Spec:** `docs/superpowers/specs/2025-05-23-storage-entry-sms-notification-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### 新建文件
|
||
|
||
| 文件 | 职责 |
|
||
|---|---|
|
||
| `docs/sql/biz_storage_entry_sms_notification.sql` | 建表 DDL |
|
||
| `backend/app/crud/storage_entry_sms_notification.py` | CRUD 层:联查、懒创建、短信生成 |
|
||
| `backend/app/api/storage_entry_sms_notification.py` | API 路由:3 个端点 |
|
||
| `web-admin/src/api/storage-entry-sms-notification.ts` | 前端 API 封装 |
|
||
| `web-admin/src/views/cell-storage/storage-entry-info/index.vue` | 前端页面 |
|
||
|
||
### 修改文件
|
||
|
||
| 文件 | 修改内容 |
|
||
|---|---|
|
||
| `backend/app/models/models.py` | 追加 `BizStorageEntrySmsNotification` 模型类 |
|
||
| `backend/app/api/__init__.py` | 导入并注册新路由 |
|
||
| `backend/app/main.py` | include_router 挂载新路由 |
|
||
| `web-admin/src/router/modules/sqxp.ts` | CellStorage 下添加路由条目 |
|
||
|
||
---
|
||
|
||
## Task 1: 建表 DDL
|
||
|
||
**Files:**
|
||
- Create: `docs/sql/biz_storage_entry_sms_notification.sql`
|
||
|
||
- [ ] **Step 1: 创建 SQL 文件**
|
||
|
||
```sql
|
||
-- 样本入库短信通知记录表
|
||
CREATE TABLE biz_storage_entry_sms_notification (
|
||
id VARCHAR(64) NOT NULL COMMENT 'UUID',
|
||
storage_review_id VARCHAR(64) NOT NULL COMMENT '关联 biz_storage_release_review.id',
|
||
protocol_id VARCHAR(64) DEFAULT NULL COMMENT '关联采集协议单ID(冗余)',
|
||
sms_content VARCHAR(500) DEFAULT NULL COMMENT '生成的短信内容',
|
||
sms_notified TINYINT(1) DEFAULT 0 COMMENT '0=未通知 1=已通知',
|
||
notified_by VARCHAR(64) DEFAULT NULL COMMENT '通知操作人ID',
|
||
notified_time DATETIME DEFAULT NULL COMMENT '通知时间',
|
||
-- 公共字段
|
||
tenant_no VARCHAR(32) DEFAULT NULL,
|
||
order_num INT DEFAULT 0,
|
||
remark VARCHAR(500) DEFAULT NULL,
|
||
create_by VARCHAR(64) DEFAULT NULL,
|
||
create_dept_id VARCHAR(64) DEFAULT NULL,
|
||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
update_by VARCHAR(64) DEFAULT NULL,
|
||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
del_flag CHAR(1) DEFAULT '0',
|
||
PRIMARY KEY (id),
|
||
KEY idx_storage_review_id (storage_review_id),
|
||
KEY idx_protocol_id (protocol_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本入库短信通知记录';
|
||
```
|
||
|
||
- [ ] **Step 2: 在数据库执行建表**
|
||
|
||
Run: `source venv/bin/activate && python -c "from app.core.database import engine; from sqlalchemy import text; import os; sql=open('docs/sql/biz_storage_entry_sms_notification.sql').read(); conn=engine.connect(); conn.execute(text(sql)); conn.commit(); print('OK')"`
|
||
|
||
或直接在 MySQL 客户端执行该 SQL 文件。
|
||
|
||
---
|
||
|
||
## Task 2: ORM 模型
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/models/models.py` — 在 `BizSampleSmsNotification` 类之后追加
|
||
|
||
- [ ] **Step 1: 追加模型类**
|
||
|
||
在 `BizSampleSmsNotification` 类(约第 1117 行 `del_flag` 之后)追加:
|
||
|
||
```python
|
||
|
||
|
||
class BizStorageEntrySmsNotification(Base):
|
||
"""样本入库短信通知 biz_storage_entry_sms_notification"""
|
||
|
||
__tablename__ = "biz_storage_entry_sms_notification"
|
||
|
||
id = Column(String(64), primary_key=True)
|
||
storage_review_id = Column(String(64), nullable=False, comment="关联 biz_storage_release_review.id")
|
||
protocol_id = Column(String(64), comment="关联采集协议单ID")
|
||
sms_content = Column(String(500), comment="生成的短信内容")
|
||
sms_notified = Column(SmallInteger, default=0, comment="0=未通知 1=已通知")
|
||
notified_by = Column(String(64), comment="通知操作人ID")
|
||
notified_time = Column(DateTime, comment="通知时间")
|
||
tenant_no = Column(String(32))
|
||
order_num = Column(Integer, default=0)
|
||
remark = Column(String(500))
|
||
create_by = Column(String(64))
|
||
create_dept_id = Column(String(64))
|
||
create_time = Column(DateTime, default=datetime.now)
|
||
update_by = Column(String(64))
|
||
update_time = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||
del_flag = Column(String(1), default="0")
|
||
```
|
||
|
||
- [ ] **Step 2: 验证 import 无报错**
|
||
|
||
Run: `cd backend && source venv/bin/activate && python -c "from app.models.models import BizStorageEntrySmsNotification; print('Model OK:', BizStorageEntrySmsNotification.__tablename__)"`
|
||
|
||
Expected: `Model OK: biz_storage_entry_sms_notification`
|
||
|
||
---
|
||
|
||
## Task 3: 后端 CRUD 层
|
||
|
||
**Files:**
|
||
- Create: `backend/app/crud/storage_entry_sms_notification.py`
|
||
|
||
**参考文件**: `backend/app/crud/sample_sms_notification.py`(完整克隆并调整)
|
||
|
||
- [ ] **Step 1: 创建 CRUD 文件**
|
||
|
||
关键差异点(相对 sample_sms_notification.py):
|
||
- 主表从 `BizSampleReception` → `BizStorageReleaseReview`
|
||
- 通知表从 `BizSampleSmsNotification` → `BizStorageEntrySmsNotification`
|
||
- 外键字段从 `sample_reception_id` → `storage_review_id`
|
||
- 过滤条件:`reception_source='cell_storage'`,`(storage_status='yrk' OR review_conclusion='btg')`
|
||
- 排序:`BizStorageReleaseReview.create_time.desc()`
|
||
- 短信模板:入库/拒绝入库两种文案
|
||
- `_row_to_dict` 字段调整:`storageReviewId`、`storageStatus`、`storageStatusLabel`、`storageTime`
|
||
|
||
```python
|
||
"""样本入库短信通知 CRUD"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from sqlalchemy import and_, func, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.utils import generate_uuid
|
||
from app.models.models import (
|
||
BizCellSampleType,
|
||
BizCollectionProtocol,
|
||
BizProtocolPartyCustomer,
|
||
BizProtocolSample,
|
||
BizStorageEntrySmsNotification,
|
||
BizStorageReleaseReview,
|
||
)
|
||
|
||
_UNICODE_CI = "utf8mb4_unicode_ci"
|
||
|
||
|
||
def _unicode_ci(col):
|
||
return col.collate(_UNICODE_CI)
|
||
|
||
|
||
def total_pages(total: int, page_size: int) -> int:
|
||
if total <= 0:
|
||
return 0
|
||
return int(math.ceil(total / float(page_size)))
|
||
|
||
|
||
def _fmt_dt(t: Optional[datetime]) -> str:
|
||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||
|
||
|
||
def _storage_status_label(row: BizStorageReleaseReview) -> str:
|
||
"""入库状态标签:已入库 or 拒绝入库"""
|
||
if row.storage_status == "yrk":
|
||
return "已入库"
|
||
if row.review_conclusion == "btg":
|
||
return "拒绝入库"
|
||
return "待入库"
|
||
|
||
|
||
def _row_to_dict(
|
||
*,
|
||
notification_id: str = "",
|
||
storage_review_id: str = "",
|
||
protocol_id: str = "",
|
||
sms_notified: int = 0,
|
||
sms_content: str = "",
|
||
protocol_no: str = "",
|
||
mother_name: str = "",
|
||
newborn_name: str = "",
|
||
sample_code: str = "",
|
||
sample_type_name: str = "",
|
||
storage_time: str = "",
|
||
storage_status_label: str = "",
|
||
collection_protocol_id: str = "",
|
||
) -> Dict[str, Any]:
|
||
return {
|
||
"id": notification_id,
|
||
"storageReviewId": storage_review_id,
|
||
"protocolId": protocol_id,
|
||
"protocolNo": protocol_no,
|
||
"motherName": mother_name,
|
||
"newbornName": newborn_name,
|
||
"sampleCode": sample_code,
|
||
"sampleTypeName": sample_type_name,
|
||
"storageTime": storage_time,
|
||
"storageStatusLabel": storage_status_label,
|
||
"smsNotified": sms_notified,
|
||
"smsContent": sms_content,
|
||
"collectionProtocolId": collection_protocol_id,
|
||
}
|
||
|
||
|
||
def _base_query(db: Session, tenant_no: str):
|
||
return (
|
||
db.query(
|
||
BizStorageReleaseReview,
|
||
BizStorageEntrySmsNotification,
|
||
BizCollectionProtocol,
|
||
BizProtocolPartyCustomer,
|
||
BizProtocolSample,
|
||
)
|
||
.outerjoin(
|
||
BizStorageEntrySmsNotification,
|
||
and_(
|
||
_unicode_ci(BizStorageEntrySmsNotification.storage_review_id)
|
||
== _unicode_ci(BizStorageReleaseReview.id),
|
||
_unicode_ci(BizStorageEntrySmsNotification.tenant_no) == tenant_no,
|
||
BizStorageEntrySmsNotification.del_flag == "0",
|
||
),
|
||
)
|
||
.outerjoin(
|
||
BizCollectionProtocol,
|
||
and_(
|
||
BizCollectionProtocol.id == BizStorageReleaseReview.protocol_id,
|
||
BizCollectionProtocol.del_flag == 0,
|
||
),
|
||
)
|
||
.outerjoin(
|
||
BizProtocolPartyCustomer,
|
||
and_(
|
||
BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id,
|
||
BizProtocolPartyCustomer.del_flag == 0,
|
||
),
|
||
)
|
||
.outerjoin(
|
||
BizProtocolSample,
|
||
and_(
|
||
_unicode_ci(BizProtocolSample.sample_code)
|
||
== _unicode_ci(BizStorageReleaseReview.sample_code),
|
||
BizProtocolSample.tenant_no == tenant_no,
|
||
BizProtocolSample.del_flag == 0,
|
||
),
|
||
)
|
||
.filter(
|
||
BizStorageReleaseReview.tenant_no == tenant_no,
|
||
BizStorageReleaseReview.del_flag == 0,
|
||
BizStorageReleaseReview.reception_source == "cell_storage",
|
||
or_(
|
||
BizStorageReleaseReview.storage_status == "yrk",
|
||
BizStorageReleaseReview.review_conclusion == "btg",
|
||
),
|
||
)
|
||
)
|
||
|
||
|
||
def _ensure_notification(
|
||
db: Session,
|
||
review: BizStorageReleaseReview,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
) -> BizStorageEntrySmsNotification:
|
||
existing = (
|
||
db.query(BizStorageEntrySmsNotification)
|
||
.filter(
|
||
BizStorageEntrySmsNotification.storage_review_id == review.id,
|
||
BizStorageEntrySmsNotification.tenant_no == tenant_no,
|
||
BizStorageEntrySmsNotification.del_flag == "0",
|
||
)
|
||
.first()
|
||
)
|
||
if existing:
|
||
return existing
|
||
|
||
obj = BizStorageEntrySmsNotification(
|
||
id=generate_uuid(),
|
||
storage_review_id=review.id,
|
||
protocol_id=review.protocol_id or "",
|
||
sms_notified=0,
|
||
tenant_no=tenant_no,
|
||
create_by=user_id,
|
||
update_by=user_id,
|
||
)
|
||
db.add(obj)
|
||
db.flush()
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def resolve_notification(
|
||
db: Session,
|
||
*,
|
||
notification_id: str,
|
||
tenant_no: str,
|
||
storage_review_id: Optional[str] = None,
|
||
user_id: Optional[str] = None,
|
||
) -> Optional[BizStorageEntrySmsNotification]:
|
||
"""Find notification by id; fallback to storage_review_id and ensure row exists."""
|
||
notif = (
|
||
db.query(BizStorageEntrySmsNotification)
|
||
.filter(
|
||
BizStorageEntrySmsNotification.id == notification_id,
|
||
BizStorageEntrySmsNotification.tenant_no == tenant_no,
|
||
BizStorageEntrySmsNotification.del_flag == "0",
|
||
)
|
||
.first()
|
||
)
|
||
if notif:
|
||
return notif
|
||
|
||
if not storage_review_id:
|
||
return None
|
||
|
||
review = (
|
||
db.query(BizStorageReleaseReview)
|
||
.filter(
|
||
BizStorageReleaseReview.id == storage_review_id,
|
||
BizStorageReleaseReview.tenant_no == tenant_no,
|
||
BizStorageReleaseReview.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not review:
|
||
return None
|
||
return _ensure_notification(db, review, tenant_no=tenant_no, user_id=user_id)
|
||
|
||
|
||
def _fetch_sample_type_name(db: Session, sample_type_id: Optional[str], tenant_no: str) -> str:
|
||
if not sample_type_id:
|
||
return ""
|
||
t = (
|
||
db.query(BizCellSampleType)
|
||
.filter(
|
||
BizCellSampleType.id == sample_type_id,
|
||
BizCellSampleType.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
return (t.type_name or "") if t else ""
|
||
|
||
|
||
def list_notifications(
|
||
db: Session,
|
||
*,
|
||
page: int = 1,
|
||
page_size: int = 10,
|
||
tenant_no: str,
|
||
keyword: Optional[str] = None,
|
||
storage_status: Optional[str] = None,
|
||
sms_notified: Optional[int] = None,
|
||
user_info=None,
|
||
) -> Tuple[List[Dict[str, Any]], int]:
|
||
if not tenant_no:
|
||
return [], 0
|
||
|
||
q = _base_query(db, tenant_no)
|
||
|
||
if keyword and keyword.strip():
|
||
kw = f"%{keyword.strip()}%"
|
||
q = q.filter(
|
||
or_(
|
||
BizCollectionProtocol.protocol_no.like(kw),
|
||
BizProtocolPartyCustomer.mother_name.like(kw),
|
||
BizStorageReleaseReview.sample_code.like(kw),
|
||
)
|
||
)
|
||
|
||
if storage_status:
|
||
if storage_status == "yrk":
|
||
q = q.filter(BizStorageReleaseReview.storage_status == "yrk")
|
||
elif storage_status == "btg":
|
||
q = q.filter(BizStorageReleaseReview.review_conclusion == "btg")
|
||
|
||
if sms_notified is not None:
|
||
if sms_notified == 0:
|
||
q = q.filter(
|
||
or_(
|
||
BizStorageEntrySmsNotification.sms_notified == 0,
|
||
BizStorageEntrySmsNotification.id.is_(None),
|
||
)
|
||
)
|
||
else:
|
||
q = q.filter(BizStorageEntrySmsNotification.sms_notified == sms_notified)
|
||
|
||
if user_info:
|
||
from app.core.data_scope import apply_data_scope
|
||
q = apply_data_scope(q, BizStorageReleaseReview, user_info, db, tenant_no)
|
||
|
||
total = q.count()
|
||
page = max(1, page)
|
||
page_size = min(max(1, page_size), 100)
|
||
|
||
rows = (
|
||
q.order_by(BizStorageReleaseReview.create_time.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
|
||
sample_type_ids = list({
|
||
ps.sample_type_id
|
||
for _, _, _, _, ps in rows
|
||
if ps and ps.sample_type_id
|
||
})
|
||
type_map: Dict[str, str] = {}
|
||
if sample_type_ids:
|
||
for t in (
|
||
db.query(BizCellSampleType)
|
||
.filter(BizCellSampleType.id.in_(sample_type_ids), BizCellSampleType.del_flag == 0)
|
||
.all()
|
||
):
|
||
type_map[t.id] = t.type_name or ""
|
||
|
||
items: List[Dict[str, Any]] = []
|
||
for review, notif, proto, party, psample in rows:
|
||
if notif is None:
|
||
notif = _ensure_notification(db, review, tenant_no=tenant_no)
|
||
|
||
sample_type_name = ""
|
||
if psample and psample.sample_type_id:
|
||
sample_type_name = type_map.get(psample.sample_type_id, "")
|
||
|
||
# 入库时间:已入库取 storage_time,拒绝入库取 review_time
|
||
display_time = ""
|
||
if review.storage_status == "yrk" and review.storage_time:
|
||
display_time = _fmt_dt(review.storage_time)
|
||
elif review.review_time:
|
||
display_time = _fmt_dt(review.review_time)
|
||
|
||
items.append(
|
||
_row_to_dict(
|
||
notification_id=notif.id,
|
||
storage_review_id=review.id,
|
||
protocol_id=notif.protocol_id or "",
|
||
sms_notified=notif.sms_notified or 0,
|
||
sms_content=notif.sms_content or "",
|
||
protocol_no=(proto.protocol_no or "") if proto else (review.protocol_no or ""),
|
||
mother_name=(party.mother_name or "") if party else "",
|
||
newborn_name=(party.newborn_name or "") if party else "",
|
||
sample_code=review.sample_code or "",
|
||
sample_type_name=sample_type_name,
|
||
storage_time=display_time,
|
||
storage_status_label=_storage_status_label(review),
|
||
collection_protocol_id=(proto.id or "") if proto else (review.protocol_id or ""),
|
||
)
|
||
)
|
||
return items, int(total)
|
||
|
||
|
||
def toggle_notification_status(
|
||
db: Session,
|
||
*,
|
||
notification_id: str,
|
||
tenant_no: str,
|
||
sms_notified: int,
|
||
user_id: Optional[str] = None,
|
||
storage_review_id: Optional[str] = None,
|
||
) -> Optional[BizStorageEntrySmsNotification]:
|
||
notif = resolve_notification(
|
||
db,
|
||
notification_id=notification_id,
|
||
tenant_no=tenant_no,
|
||
storage_review_id=storage_review_id,
|
||
user_id=user_id,
|
||
)
|
||
if not notif:
|
||
return None
|
||
|
||
notif.sms_notified = sms_notified
|
||
notif.notified_by = user_id if sms_notified == 1 else None
|
||
notif.notified_time = datetime.now() if sms_notified == 1 else None
|
||
notif.update_by = user_id
|
||
notif.update_time = datetime.now()
|
||
db.commit()
|
||
db.refresh(notif)
|
||
return notif
|
||
|
||
|
||
def generate_sms_content(
|
||
db: Session,
|
||
*,
|
||
notification_id: str,
|
||
tenant_no: str,
|
||
storage_review_id: Optional[str] = None,
|
||
user_id: Optional[str] = None,
|
||
) -> Optional[str]:
|
||
notif = resolve_notification(
|
||
db,
|
||
notification_id=notification_id,
|
||
tenant_no=tenant_no,
|
||
storage_review_id=storage_review_id,
|
||
user_id=user_id,
|
||
)
|
||
if not notif:
|
||
return None
|
||
|
||
review = (
|
||
db.query(BizStorageReleaseReview)
|
||
.filter(
|
||
BizStorageReleaseReview.id == notif.storage_review_id,
|
||
BizStorageReleaseReview.tenant_no == tenant_no,
|
||
BizStorageReleaseReview.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not review:
|
||
return None
|
||
|
||
mother_name = ""
|
||
proto = (
|
||
db.query(BizCollectionProtocol)
|
||
.filter(
|
||
BizCollectionProtocol.id == review.protocol_id,
|
||
BizCollectionProtocol.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if proto:
|
||
party = (
|
||
db.query(BizProtocolPartyCustomer)
|
||
.filter(
|
||
BizProtocolPartyCustomer.collection_protocol_id == proto.id,
|
||
BizProtocolPartyCustomer.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if party:
|
||
mother_name = party.mother_name or ""
|
||
|
||
sample_code = review.sample_code or ""
|
||
|
||
# 短信模板:区分已入库和拒绝入库
|
||
if review.storage_status == "yrk":
|
||
storage_date = _fmt_dt(review.storage_time)[:10] if review.storage_time else ""
|
||
content = (
|
||
f"尊敬的{mother_name}女士,您的样本(编码:{sample_code})"
|
||
f"已于{storage_date}完成入库处理。如有疑问请联系客服。"
|
||
)
|
||
else:
|
||
review_date = _fmt_dt(review.review_time)[:10] if review.review_time else ""
|
||
content = (
|
||
f"尊敬的{mother_name}女士,您的样本(编码:{sample_code})"
|
||
f"入库审核未通过。如有疑问请联系客服。"
|
||
)
|
||
|
||
notif.sms_content = content
|
||
notif.update_time = datetime.now()
|
||
db.commit()
|
||
db.refresh(notif)
|
||
return content
|
||
```
|
||
|
||
- [ ] **Step 2: 验证 import 无报错**
|
||
|
||
Run: `cd backend && source venv/bin/activate && python -c "from app.crud.storage_entry_sms_notification import list_notifications; print('CRUD OK')"`
|
||
|
||
Expected: `CRUD OK`
|
||
|
||
---
|
||
|
||
## Task 4: 后端 API 路由
|
||
|
||
**Files:**
|
||
- Create: `backend/app/api/storage_entry_sms_notification.py`
|
||
|
||
**参考文件**: `backend/app/api/sample_sms_notification.py`
|
||
|
||
- [ ] **Step 1: 创建 API 路由文件**
|
||
|
||
关键差异点(相对 sample_sms_notification.py):
|
||
- 菜单码:`cell-storage:storage-entry-info:list`
|
||
- CRUD 函数:参数名 `storage_review_id`、`storage_status`
|
||
- URL 前缀:`/storage-entry-sms-notifications`
|
||
|
||
```python
|
||
"""样本入库短信通知 API"""
|
||
from typing import 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_menu_code
|
||
from app.core.response import error_response, success_response
|
||
from app.crud import storage_entry_sms_notification as svc
|
||
from app.crud.collection_protocol import total_pages
|
||
from app.models.models import SysUser
|
||
|
||
router = APIRouter(prefix="/web/admin", tags=["样本入库短信通知"])
|
||
|
||
|
||
def _get_tenant_no(db: Session, user: dict) -> str:
|
||
user_id = user.get("user_id")
|
||
if not user_id:
|
||
raise PermissionError("用户身份信息缺失,无法确定租户")
|
||
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||
if not sys_user or not sys_user.tenant_no:
|
||
raise PermissionError("用户租户信息缺失")
|
||
return sys_user.tenant_no
|
||
|
||
|
||
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 ToggleStatusBody(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
|
||
sms_notified: int = Field(..., alias="smsNotified")
|
||
storage_review_id: Optional[str] = Field(None, alias="storageReviewId")
|
||
|
||
|
||
@router.get("/storage-entry-sms-notifications/list")
|
||
async def list_notifications(
|
||
page: int = Query(1, ge=1),
|
||
pageSize: int = Query(10, ge=1, le=100),
|
||
keyword: Optional[str] = None,
|
||
storageStatus: Optional[str] = None,
|
||
smsNotified: Optional[int] = None,
|
||
db: Session = Depends(get_db),
|
||
user: dict = Depends(require_menu_code("cell-storage:storage-entry-info:list")),
|
||
):
|
||
try:
|
||
tenant_no = _get_tenant_no(db, user)
|
||
items, total = svc.list_notifications(
|
||
db,
|
||
page=page,
|
||
page_size=pageSize,
|
||
tenant_no=tenant_no,
|
||
keyword=keyword,
|
||
storage_status=storageStatus,
|
||
sms_notified=smsNotified,
|
||
user_info=user,
|
||
)
|
||
return success_response(_page_payload(items, total, page, pageSize))
|
||
except PermissionError as e:
|
||
return error_response(403, str(e))
|
||
except Exception as e:
|
||
return error_response(400, str(e))
|
||
|
||
|
||
@router.put("/storage-entry-sms-notifications/{notification_id}/toggle-status")
|
||
async def toggle_status(
|
||
notification_id: str,
|
||
body: ToggleStatusBody,
|
||
db: Session = Depends(get_db),
|
||
user: dict = Depends(require_menu_code("cell-storage:storage-entry-info:list")),
|
||
):
|
||
try:
|
||
tenant_no = _get_tenant_no(db, user)
|
||
user_id = _get_current_user_id(user)
|
||
result = svc.toggle_notification_status(
|
||
db,
|
||
notification_id=notification_id,
|
||
tenant_no=tenant_no,
|
||
sms_notified=body.sms_notified,
|
||
user_id=user_id,
|
||
storage_review_id=body.storage_review_id,
|
||
)
|
||
if not result:
|
||
return error_response(404, "通知记录不存在")
|
||
return success_response(None)
|
||
except PermissionError as e:
|
||
return error_response(403, str(e))
|
||
except Exception as e:
|
||
return error_response(400, str(e))
|
||
|
||
|
||
@router.post("/storage-entry-sms-notifications/{notification_id}/generate-sms")
|
||
async def generate_sms(
|
||
notification_id: str,
|
||
storageReviewId: Optional[str] = Query(None),
|
||
db: Session = Depends(get_db),
|
||
user: dict = Depends(require_menu_code("cell-storage:storage-entry-info:list")),
|
||
):
|
||
try:
|
||
tenant_no = _get_tenant_no(db, user)
|
||
user_id = _get_current_user_id(user)
|
||
content = svc.generate_sms_content(
|
||
db,
|
||
notification_id=notification_id,
|
||
tenant_no=tenant_no,
|
||
storage_review_id=storageReviewId,
|
||
user_id=user_id,
|
||
)
|
||
if content is None:
|
||
return error_response(404, "通知记录不存在")
|
||
return success_response({"smsContent": content})
|
||
except PermissionError as e:
|
||
return error_response(403, str(e))
|
||
except Exception as e:
|
||
return error_response(400, str(e))
|
||
```
|
||
|
||
- [ ] **Step 2: 验证 import 无报错**
|
||
|
||
Run: `cd backend && source venv/bin/activate && python -c "from app.api.storage_entry_sms_notification import router; print('API OK, routes:', len(router.routes))"`
|
||
|
||
Expected: `API OK, routes: 3`
|
||
|
||
---
|
||
|
||
## Task 5: 后端路由注册
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/__init__.py`
|
||
- Modify: `backend/app/main.py`
|
||
|
||
- [ ] **Step 1: 在 `__init__.py` 追加 import 和 __all__ 条目**
|
||
|
||
在 `sample_sms_notification_router` 之后追加:
|
||
|
||
```python
|
||
from app.api.storage_entry_sms_notification import router as storage_entry_sms_notification_router
|
||
```
|
||
|
||
在 `__all__` 列表的 `"sample_sms_notification_router"` 之后追加:
|
||
|
||
```python
|
||
"storage_entry_sms_notification_router",
|
||
```
|
||
|
||
- [ ] **Step 2: 在 `main.py` 追加 include_router**
|
||
|
||
在 `app.include_router(sample_sms_notification_router, prefix=settings.API_V1_PREFIX)` 之后追加:
|
||
|
||
```python
|
||
app.include_router(storage_entry_sms_notification_router, prefix=settings.API_V1_PREFIX)
|
||
```
|
||
|
||
- [ ] **Step 3: 启动后端验证路由挂载成功**
|
||
|
||
Run: `cd backend && source venv/bin/activate && python -c "from app.main import app; routes = [r.path for r in app.routes if hasattr(r, 'path') and 'storage-entry-sms' in r.path]; print('Found', len(routes), 'routes:', routes)"`
|
||
|
||
Expected: `Found 3 routes: ['/api/storage-entry-sms-notifications/list', ...]`
|
||
|
||
---
|
||
|
||
## Task 6: 前端 API 模块
|
||
|
||
**Files:**
|
||
- Create: `web-admin/src/api/storage-entry-sms-notification.ts`
|
||
|
||
- [ ] **Step 1: 创建 API 文件**
|
||
|
||
```typescript
|
||
import request from '@/utils/http'
|
||
|
||
type PageList<T> = {
|
||
list: T[]
|
||
total: number
|
||
page: number
|
||
pageSize: number
|
||
totalPages: number
|
||
}
|
||
|
||
/** 查询列表(联查 storage_release_review + sms_notification) */
|
||
export function fetchStorageEntrySmsList(params?: {
|
||
page?: number
|
||
pageSize?: number
|
||
keyword?: string
|
||
storageStatus?: string
|
||
smsNotified?: string
|
||
}) {
|
||
return request.get<PageList<Record<string, unknown>>>({
|
||
url: '/web/admin/storage-entry-sms-notifications/list',
|
||
params
|
||
})
|
||
}
|
||
|
||
/** 切换通知状态 */
|
||
export function fetchToggleStorageEntrySmsNotified(
|
||
id: string,
|
||
data: { smsNotified: number; storageReviewId?: string }
|
||
) {
|
||
return request.put({
|
||
url: `/web/admin/storage-entry-sms-notifications/${id}/toggle-status`,
|
||
data
|
||
})
|
||
}
|
||
|
||
/** 生成短信内容 */
|
||
export function fetchGenerateStorageEntrySms(id: string, storageReviewId?: string) {
|
||
return request.post<{ smsContent: string }>({
|
||
url: `/web/admin/storage-entry-sms-notifications/${id}/generate-sms`,
|
||
params: storageReviewId ? { storageReviewId } : undefined
|
||
})
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 前端页面组件
|
||
|
||
**Files:**
|
||
- Create: `web-admin/src/views/cell-storage/storage-entry-info/index.vue`
|
||
|
||
**参考文件**: `web-admin/src/views/cell-storage/sample-reception-info/index.vue`
|
||
|
||
- [ ] **Step 1: 创建页面组件**
|
||
|
||
关键差异点(相对 sample-reception-info/index.vue):
|
||
- 页面标题、描述 → 样本入库信息
|
||
- 面板标题 → 样本入库列表
|
||
- 筛选:`acceptConclusion` → `storageStatus`(已入库/拒绝入库)
|
||
- API → `storage-entry-sms-notification` 的 3 个函数
|
||
- 表格列:接收时间→入库时间,接收结论→入库状态
|
||
- 入库状态 Tag:已入库=`success`,拒绝入库=`danger`
|
||
- 组件名 → `CellStorageStorageEntryInfo`
|
||
|
||
```vue
|
||
<!-- 样本入库信息 -->
|
||
<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.storageStatus" placeholder="全部" clearable style="width: 100%">
|
||
<el-option label="已入库" value="yrk" />
|
||
<el-option label="拒绝入库" value="btg" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="通知状态">
|
||
<el-select v-model="searchForm.smsNotified" 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-body list-wrap">
|
||
<el-table
|
||
class="list-table"
|
||
:data="tableData"
|
||
v-loading="loading"
|
||
stripe
|
||
row-key="id"
|
||
@row-dblclick="handleView"
|
||
>
|
||
<el-table-column
|
||
type="index"
|
||
label="序号"
|
||
min-width="68"
|
||
align="center"
|
||
header-align="center"
|
||
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
|
||
/>
|
||
<el-table-column prop="protocolNo" label="协议号" min-width="140" align="center" header-align="center" />
|
||
<el-table-column prop="motherName" label="母亲姓名" min-width="100" align="center" header-align="center" />
|
||
<el-table-column prop="newbornName" label="新生儿姓名" min-width="110" align="center" header-align="center" show-overflow-tooltip />
|
||
<el-table-column prop="sampleCode" label="样本编码" min-width="130" align="center" header-align="center" />
|
||
<el-table-column prop="sampleTypeName" label="样本类型" min-width="110" align="center" header-align="center" />
|
||
<el-table-column prop="storageTime" label="入库时间" min-width="170" align="center" header-align="center" />
|
||
<el-table-column prop="storageStatusLabel" label="入库状态" min-width="100" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.storageStatusLabel === '已入库' ? 'success' : 'danger'" size="small">
|
||
{{ row.storageStatusLabel }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="smsNotified" label="通知状态" min-width="100" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.smsNotified == 1 ? 'success' : 'info'" size="small">
|
||
{{ row.smsNotified == 1 ? '已通知' : '未通知' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="260" fixed="right" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
<el-button class="btn-op-item primary" @click="handleView(row)">
|
||
<el-icon><View /></el-icon>查看
|
||
</el-button>
|
||
<el-button class="btn-op-item" @click="handleExtractSms(row)">
|
||
<el-icon><ChatDotRound /></el-icon>提取短信
|
||
</el-button>
|
||
<el-button class="btn-op-item" @click="handleToggleNotified(row)">
|
||
<el-icon><Bell /></el-icon>{{ row.smsNotified == 1 ? '标记未通知' : '标记已通知' }}
|
||
</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>
|
||
|
||
<ProtocolViewDialog
|
||
v-model="viewVisible"
|
||
:protocol-id="viewProtocolId"
|
||
:list-row="viewRow"
|
||
show-audit-history
|
||
show-storage-cert
|
||
show-fullmoon-insurance
|
||
/>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Search, RefreshLeft, View, ChatDotRound, Bell } from '@element-plus/icons-vue'
|
||
import { useClipboard } from '@vueuse/core'
|
||
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
|
||
import {
|
||
fetchStorageEntrySmsList,
|
||
fetchToggleStorageEntrySmsNotified,
|
||
fetchGenerateStorageEntrySms
|
||
} from '@/api/storage-entry-sms-notification'
|
||
import { type CollectionProtocolRow } from '@/api/collection-protocol'
|
||
|
||
defineOptions({ name: 'CellStorageStorageEntryInfo' })
|
||
|
||
const searchForm = reactive({ keyword: '', storageStatus: '', smsNotified: '' })
|
||
const tableData = ref<any[]>([])
|
||
const loading = ref(false)
|
||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||
|
||
const viewVisible = ref(false)
|
||
const viewProtocolId = ref<string | null>(null)
|
||
const viewRow = ref<CollectionProtocolRow | null>(null)
|
||
|
||
const { copy } = useClipboard()
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const result = await fetchStorageEntrySmsList({
|
||
page: pagination.page,
|
||
pageSize: pagination.pageSize,
|
||
keyword: searchForm.keyword || undefined,
|
||
storageStatus: searchForm.storageStatus || undefined,
|
||
smsNotified: searchForm.smsNotified || undefined
|
||
})
|
||
const res = (result as any) || {}
|
||
tableData.value = res.list || []
|
||
pagination.total = res.total || 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.page = 1
|
||
loadData()
|
||
}
|
||
|
||
function handleReset() {
|
||
Object.assign(searchForm, { keyword: '', storageStatus: '', smsNotified: '' })
|
||
handleSearch()
|
||
}
|
||
|
||
function handleView(row: Record<string, unknown>) {
|
||
const pid = String(row.collectionProtocolId || row.protocolId || '')
|
||
if (!pid) {
|
||
ElMessage.warning('缺少关联协议单,无法查看')
|
||
return
|
||
}
|
||
viewProtocolId.value = pid
|
||
viewRow.value = {
|
||
id: pid,
|
||
protocolNo: row.protocolNo,
|
||
motherName: row.motherName,
|
||
newbornName: row.newbornName
|
||
}
|
||
viewVisible.value = true
|
||
}
|
||
|
||
async function handleExtractSms(row: any) {
|
||
try {
|
||
const result = await fetchGenerateStorageEntrySms(row.id, row.storageReviewId)
|
||
const content = (result as any)?.smsContent || ''
|
||
if (!content) {
|
||
ElMessage.warning('短信内容为空')
|
||
return
|
||
}
|
||
await copy(content)
|
||
ElMessage.success('短信内容已复制到剪贴板')
|
||
} catch {
|
||
ElMessage.error('提取短信失败')
|
||
}
|
||
}
|
||
|
||
async function handleToggleNotified(row: any) {
|
||
const newVal = row.smsNotified == 1 ? 0 : 1
|
||
try {
|
||
await fetchToggleStorageEntrySmsNotified(row.id, {
|
||
smsNotified: newVal,
|
||
storageReviewId: row.storageReviewId
|
||
})
|
||
ElMessage.success(newVal === 1 ? '已标记为已通知' : '已标记为未通知')
|
||
loadData()
|
||
} catch {
|
||
ElMessage.error('操作失败')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadData()
|
||
})
|
||
</script>
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 前端路由注册
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/router/modules/sqxp.ts`
|
||
|
||
- [ ] **Step 1: 在 CellStorage 下的 `sample-reception-info` 条目之后追加**
|
||
|
||
在 `sample-reception-info` 路由块(约第 260 行)之后,`storage-cert` 路由块之前,插入:
|
||
|
||
```typescript
|
||
{
|
||
id: '50000000000000000000000000000012',
|
||
name: 'CellStorageStorageEntryInfo',
|
||
path: 'storage-entry-info',
|
||
component: '/cell-storage/storage-entry-info/index',
|
||
meta: { title: '样本入库信息', keepAlive: true, menuCode: 'cell-storage:storage-entry-info:list' }
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: 验证前端编译无报错**
|
||
|
||
Run: `cd web-admin && pnpm build 2>&1 | tail -5`
|
||
|
||
Expected: 构建成功,无 TypeScript 错误。
|
||
|
||
---
|
||
|
||
## Task 9: 端到端验证
|
||
|
||
- [ ] **Step 1: 启动后端,通过 Swagger 验证 API**
|
||
|
||
Run: `cd backend && source venv/bin/activate && ./start-local.sh`
|
||
|
||
访问 `http://localhost:8000/docs`,检查:
|
||
- `GET /api/storage-entry-sms-notifications/list` 存在
|
||
- `PUT /api/storage-entry-sms-notifications/{id}/toggle-status` 存在
|
||
- `POST /api/storage-entry-sms-notifications/{id}/generate-sms` 存在
|
||
|
||
- [ ] **Step 2: 启动前端,访问页面**
|
||
|
||
Run: `cd web-admin && pnpm dev`
|
||
|
||
访问 `http://localhost:3006/#/cell-storage/storage-entry-info`,验证:
|
||
- 页面正常渲染,筛选区和表格列正确
|
||
- 列表数据加载正常(需后台有已入库记录)
|
||
- 双击行 / 点击「查看」打开 ProtocolViewDialog
|
||
- 「提取短信」生成内容并复制到剪贴板
|
||
- 「标记已/未通知」状态切换正常
|
||
- 浏览器窗口缩放时,各列等比伸缩(min-width 生效)
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add docs/sql/biz_storage_entry_sms_notification.sql
|
||
git add backend/app/models/models.py
|
||
git add backend/app/crud/storage_entry_sms_notification.py
|
||
git add backend/app/api/storage_entry_sms_notification.py
|
||
git add backend/app/api/__init__.py
|
||
git add backend/app/main.py
|
||
git add web-admin/src/api/storage-entry-sms-notification.ts
|
||
git add web-admin/src/views/cell-storage/storage-entry-info/index.vue
|
||
git add web-admin/src/router/modules/sqxp.ts
|
||
git commit -m "feat: 新增样本入库信息页面,支持短信通知管理(克隆样本接收信息模式)"
|
||
```
|