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:
1 parent
66fc507b1a
commit
6d542a0acf
2 files changed
+559
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user