feat(backend): add sample freight CRUD and link/unlink

Provide freight batch create/update/link/unlink, sample enrichment map, and protocol-save draft grouping without writing legacy transport rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-08 07:47:41 +08:00
1 parent 66fc507b1a
commit 6d542a0acf
2 files changed
+559

No files matched your search

+537
View File
@@ -0,0 +1,537 @@
"""样本运费批次 CRUD(biz_sample_freight / biz_sample_freight_item)"""
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.models.models import BizSampleFreight, BizSampleFreightItem, SysDictData
from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value
SAMPLE_SOURCE_CELL = "cell_storage"
SAMPLE_SOURCE_DONATION = "research_donation"
def _now() -> datetime:
return datetime.now()
def _fmt_dt(t: Optional[datetime]) -> str:
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
def _parse_amount(amount: Any) -> Decimal:
if amount is None or amount == "":
raise ValueError("运费不能为空")
try:
val = Decimal(str(amount))
except Exception as exc:
raise ValueError("运费格式无效") from exc
if val < 0:
raise ValueError("运费不能为负数")
return val
def _item_field(raw: Dict[str, Any], camel: str, snake: str, default: str = "") -> str:
v = raw.get(camel)
if v is None or v == "":
v = raw.get(snake)
if v is None:
return default
return str(v).strip()
def _normalize_item(raw: Any) -> Dict[str, Any]:
if not isinstance(raw, dict):
raise ValueError("关联样本项无效")
sample_source = _item_field(raw, "sampleSource", "sample_source")
sample_id = _item_field(raw, "sampleId", "sample_id")
if not sample_source or not sample_id:
raise ValueError("关联样本须包含 sampleSource 与 sampleId")
if sample_source not in (SAMPLE_SOURCE_CELL, SAMPLE_SOURCE_DONATION):
raise ValueError("sampleSource 无效")
protocol_id = _item_field(raw, "protocolId", "protocol_id")
sample_code = _item_field(raw, "sampleCode", "sample_code")
return {
"sample_source": sample_source,
"sample_id": sample_id,
"protocol_id": protocol_id or None,
"sample_code": sample_code or None,
}
def _label_map(db: Session, tenant_no: str, modes: List[str]) -> Dict[str, str]:
uniq = [m for m in {str(x).strip() for x in modes if str(x or "").strip()}]
if not uniq:
return {}
rows = (
db.query(SysDictData.dict_value, SysDictData.dict_label)
.filter(
SysDictData.dict_type == DICT_TYPE,
SysDictData.tenant_no == tenant_no,
SysDictData.dict_value.in_(uniq),
SysDictData.del_flag == 0,
)
.all()
)
return {str(v): (lbl or v or "") for v, lbl in rows}
def _linked_count(db: Session, freight_id: str, tenant_no: str) -> int:
return (
db.query(func.count(BizSampleFreightItem.id))
.filter(
BizSampleFreightItem.freight_id == freight_id,
BizSampleFreightItem.tenant_no == tenant_no,
BizSampleFreightItem.del_flag == 0,
)
.scalar()
or 0
)
def _row_to_dict(
obj: BizSampleFreight,
*,
linked_sample_count: int = 0,
transport_mode_label: str = "",
) -> Dict[str, Any]:
amount = obj.freight_amount
return {
"id": obj.id,
"transportMode": obj.transport_mode or "",
"transportModeLabel": transport_mode_label or (obj.transport_mode or ""),
"freightAmount": float(amount) if amount is not None else 0.0,
"linkedSampleCount": int(linked_sample_count or 0),
"tenantNo": obj.tenant_no,
"orderNum": obj.order_num if obj.order_num is not None else 0,
"remark": obj.remark or "",
"createTime": _fmt_dt(obj.create_time),
"updateTime": _fmt_dt(obj.update_time),
}
def _item_to_dict(item: BizSampleFreightItem) -> Dict[str, Any]:
return {
"id": item.id,
"freightId": item.freight_id,
"sampleSource": item.sample_source or "",
"sampleId": item.sample_id or "",
"sampleCode": item.sample_code or "",
"protocolId": item.protocol_id or "",
}
def get_freight(db: Session, freight_id: str, tenant_no: str) -> Optional[BizSampleFreight]:
return (
db.query(BizSampleFreight)
.filter(
BizSampleFreight.id == freight_id,
BizSampleFreight.tenant_no == tenant_no,
BizSampleFreight.del_flag == 0,
)
.first()
)
def list_freights(
db: Session,
*,
tenant_no: str,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
page = max(1, int(page or 1))
page_size = max(1, min(100, int(page_size or 10)))
q = db.query(BizSampleFreight).filter(
BizSampleFreight.tenant_no == tenant_no,
BizSampleFreight.del_flag == 0,
)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
label_values = [
r[0]
for r in db.query(SysDictData.dict_value)
.filter(
SysDictData.dict_type == DICT_TYPE,
SysDictData.tenant_no == tenant_no,
SysDictData.del_flag == 0,
SysDictData.dict_label.like(like),
)
.all()
]
conds = [BizSampleFreight.transport_mode.like(like)]
if label_values:
conds.append(BizSampleFreight.transport_mode.in_(label_values))
q = q.filter(or_(*conds))
total = q.count()
rows = (
q.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
labels = _label_map(db, tenant_no, [r.transport_mode for r in rows])
out = [
_row_to_dict(
r,
linked_sample_count=_linked_count(db, r.id, tenant_no),
transport_mode_label=labels.get(r.transport_mode or "", r.transport_mode or ""),
)
for r in rows
]
return out, total
def get_freight_detail(db: Session, freight_id: str, tenant_no: str) -> Dict[str, Any]:
obj = get_freight(db, freight_id, tenant_no)
if not obj:
raise ValueError("运费批次不存在")
items = (
db.query(BizSampleFreightItem)
.filter(
BizSampleFreightItem.freight_id == freight_id,
BizSampleFreightItem.tenant_no == tenant_no,
BizSampleFreightItem.del_flag == 0,
)
.order_by(BizSampleFreightItem.create_time.asc())
.all()
)
labels = _label_map(db, tenant_no, [obj.transport_mode])
data = _row_to_dict(
obj,
linked_sample_count=len(items),
transport_mode_label=labels.get(obj.transport_mode or "", obj.transport_mode or ""),
)
data["items"] = [_item_to_dict(i) for i in items]
return data
def _upsert_item(
db: Session,
*,
freight_id: str,
tenant_no: str,
user_id: Optional[str],
item: Dict[str, Any],
) -> None:
norm = _normalize_item(item)
existing = (
db.query(BizSampleFreightItem)
.filter(
BizSampleFreightItem.tenant_no == tenant_no,
BizSampleFreightItem.sample_source == norm["sample_source"],
BizSampleFreightItem.sample_id == norm["sample_id"],
)
.order_by(BizSampleFreightItem.del_flag.asc(), BizSampleFreightItem.create_time.desc())
.first()
)
now = _now()
if existing:
existing.freight_id = freight_id
existing.protocol_id = norm["protocol_id"] or existing.protocol_id
existing.sample_code = norm["sample_code"] or existing.sample_code
existing.del_flag = 0
existing.update_by = user_id
existing.update_time = now
db.add(existing)
return
db.add(
BizSampleFreightItem(
id=generate_uuid(),
freight_id=freight_id,
sample_source=norm["sample_source"],
sample_id=norm["sample_id"],
sample_code=norm["sample_code"],
protocol_id=norm["protocol_id"],
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
def create_freight(
db: Session,
*,
tenant_no: str,
user_id: Optional[str],
transport_mode_raw: str,
freight_amount: Any,
items: Optional[List[Dict[str, Any]]] = None,
commit: bool = True,
) -> Dict[str, Any]:
mode = resolve_transport_mode_value(
db, tenant_no=tenant_no, raw=transport_mode_raw, user_id=user_id
)
amount = _parse_amount(freight_amount)
now = _now()
obj = BizSampleFreight(
id=generate_uuid(),
transport_mode=mode,
freight_amount=amount,
tenant_no=tenant_no,
order_num=0,
remark=None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
db.flush()
for it in items or []:
_upsert_item(db, freight_id=obj.id, tenant_no=tenant_no, user_id=user_id, item=it)
if commit:
db.commit()
else:
db.flush()
return get_freight_detail(db, obj.id, tenant_no)
def update_freight(
db: Session,
freight_id: str,
*,
tenant_no: str,
user_id: Optional[str],
transport_mode_raw: Optional[str] = None,
freight_amount: Any = None,
commit: bool = True,
) -> Dict[str, Any]:
obj = get_freight(db, freight_id, tenant_no)
if not obj:
raise ValueError("运费批次不存在")
if transport_mode_raw is not None and str(transport_mode_raw).strip() != "":
obj.transport_mode = resolve_transport_mode_value(
db, tenant_no=tenant_no, raw=str(transport_mode_raw), user_id=user_id
)
if freight_amount is not None and freight_amount != "":
obj.freight_amount = _parse_amount(freight_amount)
obj.update_by = user_id
obj.update_time = _now()
db.add(obj)
if commit:
db.commit()
else:
db.flush()
return get_freight_detail(db, freight_id, tenant_no)
def link_samples(
db: Session,
freight_id: str,
*,
tenant_no: str,
user_id: Optional[str],
items: List[Dict[str, Any]],
commit: bool = True,
) -> Dict[str, Any]:
obj = get_freight(db, freight_id, tenant_no)
if not obj:
raise ValueError("运费批次不存在")
if not items:
raise ValueError("请选择要关联的样本")
for it in items:
_upsert_item(db, freight_id=freight_id, tenant_no=tenant_no, user_id=user_id, item=it)
if commit:
db.commit()
else:
db.flush()
return get_freight_detail(db, freight_id, tenant_no)
def unlink_samples(
db: Session,
freight_id: str,
*,
tenant_no: str,
user_id: Optional[str],
items: List[Dict[str, Any]],
commit: bool = True,
) -> Dict[str, Any]:
obj = get_freight(db, freight_id, tenant_no)
if not obj:
raise ValueError("运费批次不存在")
now = _now()
for it in items or []:
norm = _normalize_item(it)
row = (
db.query(BizSampleFreightItem)
.filter(
BizSampleFreightItem.freight_id == freight_id,
BizSampleFreightItem.tenant_no == tenant_no,
BizSampleFreightItem.sample_source == norm["sample_source"],
BizSampleFreightItem.sample_id == norm["sample_id"],
BizSampleFreightItem.del_flag == 0,
)
.first()
)
if not row:
continue
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
db.flush()
remaining = _linked_count(db, freight_id, tenant_no)
if remaining == 0:
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = now
db.add(obj)
if commit:
db.commit()
else:
db.flush()
if remaining == 0:
return {
"id": freight_id,
"deleted": True,
"linkedSampleCount": 0,
}
return get_freight_detail(db, freight_id, tenant_no)
def freight_map_for_samples(
db: Session,
*,
tenant_no: str,
sample_source: str,
sample_ids: List[str],
) -> Dict[str, Dict[str, Any]]:
ids = [str(x).strip() for x in sample_ids if str(x or "").strip()]
if not ids:
return {}
rows = (
db.query(BizSampleFreightItem, BizSampleFreight)
.join(
BizSampleFreight,
and_(
BizSampleFreight.id == BizSampleFreightItem.freight_id,
BizSampleFreight.tenant_no == tenant_no,
BizSampleFreight.del_flag == 0,
),
)
.filter(
BizSampleFreightItem.tenant_no == tenant_no,
BizSampleFreightItem.sample_source == sample_source,
BizSampleFreightItem.sample_id.in_(ids),
BizSampleFreightItem.del_flag == 0,
)
.all()
)
if not rows:
return {}
freight_ids = list({f.id for _, f in rows})
count_rows = (
db.query(BizSampleFreightItem.freight_id, func.count(BizSampleFreightItem.id))
.filter(
BizSampleFreightItem.freight_id.in_(freight_ids),
BizSampleFreightItem.tenant_no == tenant_no,
BizSampleFreightItem.del_flag == 0,
)
.group_by(BizSampleFreightItem.freight_id)
.all()
)
count_map = {fid: int(c) for fid, c in count_rows}
labels = _label_map(db, tenant_no, [f.transport_mode for _, f in rows])
out: Dict[str, Dict[str, Any]] = {}
for item, freight in rows:
out[item.sample_id] = {
"id": freight.id,
"transportMode": freight.transport_mode or "",
"transportModeLabel": labels.get(
freight.transport_mode or "", freight.transport_mode or ""
),
"freightAmount": float(freight.freight_amount)
if freight.freight_amount is not None
else 0.0,
"linkedSampleCount": count_map.get(freight.id, 0),
}
return out
def apply_freight_on_protocol_save(
db: Session,
*,
tenant_no: str,
user_id: Optional[str],
sample_source: str,
protocol_id: str,
saved_samples: list,
input_samples: list,
) -> None:
"""协议保存挂钩:按 freightId / freightDraft 关联运费(flush,不单独 commit)。"""
draft_groups: Dict[str, Dict[str, Any]] = {}
for idx, sample_obj in enumerate(saved_samples or []):
if idx >= len(input_samples or []):
break
raw = input_samples[idx] if isinstance(input_samples[idx], dict) else {}
sample_id = getattr(sample_obj, "id", None) or ""
sample_code = getattr(sample_obj, "sample_code", None) or raw.get("sampleCode") or ""
if not sample_id:
continue
item = {
"sampleSource": sample_source,
"sampleId": sample_id,
"protocolId": protocol_id,
"sampleCode": sample_code,
}
freight_id = (
str(raw.get("freightId") or "").strip()
or str((raw.get("freight") or {}).get("id") or "").strip()
)
draft = raw.get("freightDraft")
if freight_id:
link_samples(
db,
freight_id,
tenant_no=tenant_no,
user_id=user_id,
items=[item],
commit=False,
)
continue
if isinstance(draft, dict):
client_key = str(draft.get("clientKey") or "").strip()
if not client_key:
client_key = f"__idx_{idx}"
group = draft_groups.get(client_key)
if not group:
draft_groups[client_key] = {
"transportMode": draft.get("transportMode") or draft.get("transport_mode"),
"freightAmount": draft.get("freightAmount")
if draft.get("freightAmount") is not None
else draft.get("freight_amount"),
"items": [item],
}
else:
group["items"].append(item)
for group in draft_groups.values():
create_freight(
db,
tenant_no=tenant_no,
user_id=user_id,
transport_mode_raw=str(group.get("transportMode") or ""),
freight_amount=group.get("freightAmount"),
items=group["items"],
commit=False,
)
db.flush()
+22
View File
@@ -1,6 +1,7 @@
"""样本运费:运输方式字典解析与关联规则"""
import unittest
from decimal import Decimal
from unittest.mock import MagicMock
from app.services.sample_transport_mode import resolve_transport_mode_value
@@ -39,5 +40,26 @@ class TestResolveTransportMode(unittest.TestCase):
db.add.assert_not_called()
class TestFreightAmountValidation(unittest.TestCase):
def test_negative_rejected(self):
from app.crud.sample_freight import _parse_amount
with self.assertRaises(ValueError):
_parse_amount(-1)
def test_zero_ok(self):
from app.crud.sample_freight import _parse_amount
self.assertEqual(_parse_amount(0), Decimal("0"))
def test_empty_rejected(self):
from app.crud.sample_freight import _parse_amount
with self.assertRaises(ValueError):
_parse_amount(None)
with self.assertRaises(ValueError):
_parse_amount("")
if __name__ == "__main__":
unittest.main()