Files
zkxw/backend/app/services/market_presign.py
zlwu bd62416263 feat(project): add team attribution and business statistics
Add tenant-scoped teams and projects, order attribution across four businesses, conversion inheritance, scoped statistics and audited reassignment. Reuse existing Web and mobile UI patterns; team regions and hospitals remain informational.

Include incremental SQL and isolated regression coverage. Actual MySQL and browser/WebView integration validation remains pending; relates to issue #13.
2026-11-02 15:29:43 +08:00

855 lines
26 KiB
Python

"""移动市场预签单服务:通用数据权限与现场范围始终组合生效。"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.services.project_assignment import (
create_assignment,
guard_project_edit,
filter_assignment,
)
from app.crud import presign_order as presign_crud
from app.models.models import (
BizCellSampleType,
BizCollectionHospital,
BizCollectionRegion,
BizCollectionRegionStaff,
BizPresignOrder,
BizPresignOrderSampleType,
BizPresignTransfer,
)
from app.schemas.market_mobile import (
MarketPresignOrderBody,
MarketPresignTransferBody,
)
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
assert_presign_access,
)
from app.services.market_support import validate_attachment_refs
_MONEY_QUANTUM = Decimal('0.01')
def _money_text(value: Optional[Decimal]) -> Optional[str]:
if value is None:
return None
return format(Decimal(str(value)).quantize(_MONEY_QUANTUM), 'f')
def _mobile_transfer_to_dict(
db: Session, row: BizPresignTransfer, tenant_no: str
) -> dict:
data = presign_crud._transfer_to_dict(db, row, tenant_no)
data['transferAmount'] = _money_text(row.transfer_amount)
return data
def _reference_data(
db: Session, rows: List[BizPresignOrder], tenant_no: str
) -> dict:
order_ids = [row.id for row in rows]
region_ids = {row.region_id for row in rows if row.region_id}
hospital_ids = {row.hospital_id for row in rows if row.hospital_id}
region_names = {}
if region_ids:
region_names = {
str(row.id): str(row.region_name or '').strip()
for row in db.query(BizCollectionRegion).filter(
BizCollectionRegion.id.in_(region_ids),
BizCollectionRegion.tenant_no == tenant_no,
)
}
hospital_names = {}
if hospital_ids:
hospital_names = {
str(row.id): str(row.hospital_name or '').strip()
for row in db.query(BizCollectionHospital).filter(
BizCollectionHospital.id.in_(hospital_ids),
BizCollectionHospital.tenant_no == tenant_no,
)
}
type_ids_by_order = {order_id: [] for order_id in order_ids}
if order_ids:
links = (
db.query(BizPresignOrderSampleType)
.filter(
BizPresignOrderSampleType.presign_order_id.in_(order_ids),
BizPresignOrderSampleType.tenant_no == tenant_no,
BizPresignOrderSampleType.del_flag == 0,
)
.order_by(
BizPresignOrderSampleType.order_num.asc(),
BizPresignOrderSampleType.create_time.asc(),
BizPresignOrderSampleType.id.asc(),
)
.all()
)
for link in links:
if link.sample_type_id:
type_ids_by_order.setdefault(link.presign_order_id, []).append(
str(link.sample_type_id)
)
type_ids = {
type_id
for order_type_ids in type_ids_by_order.values()
for type_id in order_type_ids
}
type_names = {}
if type_ids:
type_names = {
str(row.id): str(row.type_name or '').strip()
for row in db.query(BizCellSampleType).filter(
BizCellSampleType.id.in_(type_ids),
BizCellSampleType.tenant_no == tenant_no,
)
}
return {
'regionNames': region_names,
'hospitalNames': hospital_names,
'sampleTypeIds': type_ids_by_order,
'sampleTypeNames': {
order_id: [type_names.get(type_id, '') for type_id in order_type_ids]
for order_id, order_type_ids in type_ids_by_order.items()
},
}
def _mobile_order_to_dict(
db: Session,
row: BizPresignOrder,
tenant_no: str,
*,
with_children: bool,
deposit_amount: Optional[Decimal] = None,
user_name_map: Optional[dict] = None,
reference_data: Optional[dict] = None,
) -> dict:
deposit = (
deposit_amount
if deposit_amount is not None
else presign_crud.confirmed_deposit_amount(
db, presign_order_id=row.id, tenant_no=tenant_no
)
)
data = presign_crud.order_to_dict(
db,
row,
tenant_no,
with_children=False,
deposit_amount=deposit,
user_name_map=user_name_map,
)
data['contractAmount'] = _money_text(row.contract_amount)
data['confirmedDepositAmount'] = _money_text(deposit)
references = reference_data or _reference_data(db, [row], tenant_no)
data['regionName'] = references['regionNames'].get(row.region_id or '', '')
data['hospitalName'] = references['hospitalNames'].get(
row.hospital_id or '', ''
)
data['sampleTypeIds'] = references['sampleTypeIds'].get(row.id, [])
data['sampleTypeNames'] = references['sampleTypeNames'].get(row.id, [])
if with_children:
transfer_rows = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.presign_order_id == row.id,
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
)
.order_by(BizPresignTransfer.create_time.asc())
.all()
)
data['transfers'] = [
_mobile_transfer_to_dict(db, transfer, tenant_no)
for transfer in transfer_rows
]
return data
def _dump_body(body: MarketPresignOrderBody) -> dict:
return body.model_dump(by_alias=False, exclude_unset=True)
def _dump_transfer_body(body: MarketPresignTransferBody) -> dict:
return body.model_dump(by_alias=False, exclude_unset=True)
def _reject_direct_conversion(fields: dict) -> None:
if str(fields.get('order_status') or '').strip() == 'converted':
raise ValueError('预签约订单必须通过转正式操作进入已转正式状态')
def _assert_transfer_order_writable(order: BizPresignOrder) -> None:
if (order.order_status or '').strip() not in ('', 'draft', 'active'):
raise ValueError('当前状态的预签约订单不可操作转账记录')
def _scoped_order_for_update(
db: Session,
order_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> BizPresignOrder:
row = (
apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=user_info
)
.filter(BizPresignOrder.id == order_id)
.populate_existing()
.with_for_update()
.first()
)
if row is None:
raise HTTPException(status_code=404, detail='数据不存在或无权访问')
return row
def _current_dept_id(user_info: dict) -> str:
"""兼容 API 用户上下文与测试/内部调用的部门字段命名。"""
if not isinstance(user_info, dict):
return ""
return str(
user_info.get("dept_id")
or user_info.get("deptId")
or user_info.get("region_id")
or ""
).strip()
def _manager_assignment_allowed(
db: Session,
ctx: MarketFieldContext,
*,
region_id: Optional[str],
region_manager_user_id: Optional[str],
) -> bool:
region = (region_id or "").strip()
if not region:
return False
return (
db.query(BizCollectionRegionStaff.id)
.filter(
BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
BizCollectionRegionStaff.del_flag == 0,
BizCollectionRegionStaff.user_id == ctx.user_id,
BizCollectionRegionStaff.is_leader == 1,
BizCollectionRegionStaff.region_id == region,
)
.first()
is not None
)
def _ensure_manager_assignment(
db: Session,
ctx: MarketFieldContext,
*,
region_id: Optional[str],
region_manager_user_id: Optional[str],
) -> None:
if not _manager_assignment_allowed(
db,
ctx,
region_id=region_id,
region_manager_user_id=region_manager_user_id,
):
raise ValueError("预签约订单归属不在当前地区经理范围内")
def _validate_master_data(
db: Session,
ctx: MarketFieldContext,
*,
region_id: Optional[str],
hospital_id: Optional[str],
sample_type_ids: Optional[List[str]],
) -> None:
region = (region_id or "").strip()
hospital = (hospital_id or "").strip()
if region:
region_exists = (
db.query(BizCollectionRegion.id)
.filter(
BizCollectionRegion.id == region,
BizCollectionRegion.tenant_no == ctx.tenant_no,
BizCollectionRegion.del_flag == 0,
)
.first()
is not None
)
if not region_exists:
raise ValueError("采集地区不存在或不属于当前租户")
if hospital:
hospital_exists = (
db.query(BizCollectionHospital.id)
.filter(
BizCollectionHospital.id == hospital,
BizCollectionHospital.region_id == region,
BizCollectionHospital.tenant_no == ctx.tenant_no,
BizCollectionHospital.del_flag == 0,
)
.first()
is not None
)
if not hospital_exists:
raise ValueError("医院不存在、不属于当前租户或与采集地区不匹配")
if sample_type_ids is not None:
requested = {
str(sample_type_id).strip()
for sample_type_id in sample_type_ids
if str(sample_type_id or "").strip()
}
if requested:
existing = {
str(row[0])
for row in db.query(BizCellSampleType.id)
.filter(
BizCellSampleType.id.in_(requested),
BizCellSampleType.tenant_no == ctx.tenant_no,
BizCellSampleType.del_flag == 0,
)
.all()
}
if existing != requested:
raise ValueError("样本类型不存在或不属于当前租户")
def list_presign_orders(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
order_status: Optional[str] = None,
project_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> Tuple[List[dict], int]:
query = apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=user_info
)
query = filter_assignment(
query, BizPresignOrder, db, ctx.tenant_no, project_id, team_id
)
status = (order_status or "").strip()
if status:
query = query.filter(BizPresignOrder.order_status == status)
search = (keyword or "").strip()
if search:
like = f"%{search}%"
query = query.filter(
or_(
BizPresignOrder.protocol_no.like(like),
BizPresignOrder.mother_name.like(like),
)
)
total = (
query.order_by(None)
.with_entities(func.count(BizPresignOrder.id))
.scalar()
or 0
)
safe_page = max(1, page)
safe_page_size = min(max(1, page_size), 100)
rows = (
query.order_by(BizPresignOrder.create_time.desc())
.offset((safe_page - 1) * safe_page_size)
.limit(safe_page_size)
.all()
)
order_ids = [row.id for row in rows]
deposit_map = presign_crud.confirmed_deposit_amount_map(
db, presign_order_ids=order_ids, tenant_no=ctx.tenant_no
)
person_ids = [
user_id
for row in rows
for user_id in (
row.collector_user_id,
row.sales_user_id,
row.region_manager_user_id,
)
if user_id
]
user_name_map = presign_crud._user_nick_name_map(
db, person_ids, ctx.tenant_no
)
references = _reference_data(db, rows, ctx.tenant_no)
return [
_mobile_order_to_dict(
db,
row,
ctx.tenant_no,
with_children=False,
deposit_amount=deposit_map.get(row.id, 0),
user_name_map=user_name_map,
reference_data=references,
)
for row in rows
], int(total)
def get_presign_order(
db: Session,
*,
order_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
row = assert_presign_access(
db, order_id, ctx, user_info=user_info
)
references = _reference_data(db, [row], ctx.tenant_no)
return _mobile_order_to_dict(
db,
row,
ctx.tenant_no,
with_children=True,
reference_data=references,
)
def create_presign_order(
db: Session,
*,
body: MarketPresignOrderBody,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
fields = _dump_body(body)
fields["project_id"] = create_assignment(
db, ctx.tenant_no, user_info, body.project_id, "presign"
)
_reject_direct_conversion(fields)
sample_type_ids = fields.pop("sample_type_ids", None)
transfers = fields.pop("transfers", None)
if transfers and (
fields.get('order_status') or 'active'
).strip() not in ('', 'draft', 'active'):
raise ValueError('当前状态的预签约订单不可操作转账记录')
_validate_master_data(
db,
ctx,
region_id=fields.get("region_id"),
hospital_id=fields.get("hospital_id"),
sample_type_ids=sample_type_ids,
)
if ctx.scope_kind == "specialist":
fields["sales_user_id"] = ctx.user_id
else:
_ensure_manager_assignment(
db,
ctx,
region_id=fields.get("region_id"),
region_manager_user_id=fields.get("region_manager_user_id"),
)
presign_crud.validate_presign_person_ids(
db,
tenant_no=ctx.tenant_no,
collector_user_id=fields.get("collector_user_id") or ctx.user_id,
sales_user_id=fields.get("sales_user_id"),
region_manager_user_id=fields.get("region_manager_user_id"),
)
transfer_fields = [
item.model_dump(by_alias=False, exclude_unset=True)
if hasattr(item, "model_dump")
else item
for item in (transfers or [])
]
try:
row = presign_crud.create_presign_order(
db,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
fields=fields,
sample_type_ids=sample_type_ids,
transfers=transfer_fields,
create_dept_id=_current_dept_id(user_info),
commit=False,
)
created_transfers = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.presign_order_id == row.id,
BizPresignTransfer.tenant_no == ctx.tenant_no,
BizPresignTransfer.del_flag == 0,
)
.all()
)
for transfer in created_transfers:
if transfer.transfer_attachments:
transfer.transfer_attachments = validate_attachment_refs(
db,
transfer.transfer_attachments,
module='presign',
ctx=ctx,
creating=True,
business_id=row.id,
)
db.add(transfer)
db.flush()
row = assert_presign_access(
db, row.id, ctx, user_info=user_info
)
db.commit()
db.refresh(row)
except Exception:
db.rollback()
raise
return _mobile_order_to_dict(
db, row, ctx.tenant_no, with_children=True
)
def update_presign_order(
db: Session,
*,
order_id: str,
body: MarketPresignOrderBody,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
try:
current = _scoped_order_for_update(
db, order_id, ctx, user_info
)
if (current.order_status or '').strip() == 'converted':
raise ValueError('当前状态的预签约订单不可修改')
fields = _dump_body(body)
guard_project_edit(current, fields)
fields.pop("project_id", None)
_reject_direct_conversion(fields)
sample_type_ids = fields.pop("sample_type_ids", None)
fields.pop("transfers", None)
resulting_region_id = fields.get("region_id", current.region_id)
resulting_hospital_id = fields.get("hospital_id", current.hospital_id)
_validate_master_data(
db,
ctx,
region_id=resulting_region_id,
hospital_id=resulting_hospital_id,
sample_type_ids=sample_type_ids,
)
if ctx.scope_kind == "specialist":
fields["sales_user_id"] = ctx.user_id
elif str(resulting_region_id or '').strip() != str(current.region_id or '').strip():
_ensure_manager_assignment(
db,
ctx,
region_id=resulting_region_id,
region_manager_user_id=fields.get(
"region_manager_user_id", current.region_manager_user_id
),
)
presign_crud.validate_presign_person_ids(
db,
tenant_no=ctx.tenant_no,
collector_user_id=fields.get(
"collector_user_id", current.collector_user_id
),
sales_user_id=fields.get("sales_user_id", current.sales_user_id),
region_manager_user_id=fields.get(
"region_manager_user_id", current.region_manager_user_id
),
)
row = presign_crud.update_presign_order(
db,
order_id=order_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
fields=fields,
sample_type_ids=sample_type_ids,
create_dept_id=_current_dept_id(user_info),
commit=False,
)
row = assert_presign_access(
db, row.id, ctx, user_info=user_info
)
result = _mobile_order_to_dict(
db, row, ctx.tenant_no, with_children=True
)
db.commit()
return result
except Exception:
db.rollback()
raise
def delete_presign_order(
db: Session,
order_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> None:
try:
row = _scoped_order_for_update(db, order_id, ctx, user_info)
if (row.order_status or '').strip() == 'converted':
raise ValueError('已转正式的预签约订单不可删除')
presign_crud.soft_delete_presign_order(
db,
order_id=order_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
)
except Exception:
db.rollback()
raise
def _scoped_transfer(
db: Session,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> BizPresignTransfer:
probe = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.id == transfer_id,
BizPresignTransfer.tenant_no == ctx.tenant_no,
BizPresignTransfer.del_flag == 0,
)
.first()
)
if probe is None:
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
order = _scoped_order_for_update(
db, probe.presign_order_id, ctx, user_info
)
_assert_transfer_order_writable(order)
row = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.id == transfer_id,
BizPresignTransfer.tenant_no == ctx.tenant_no,
BizPresignTransfer.del_flag == 0,
)
.populate_existing()
.with_for_update()
.first()
)
if row is None:
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
return row
def _update_transfer_if_status(
db: Session,
*,
transfer_id: str,
tenant_no: str,
expected_status: str,
values: dict,
extra_conditions: tuple = (),
) -> None:
updates = dict(values)
changed = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.id == transfer_id,
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
BizPresignTransfer.pay_status == expected_status,
*extra_conditions,
)
.update(updates, synchronize_session=False)
)
if changed != 1:
raise ValueError('转账状态已变化,请刷新后重试')
def create_presign_transfer(
db: Session,
order_id: str,
body: MarketPresignTransferBody,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
try:
order = _scoped_order_for_update(
db, order_id, ctx, user_info
)
_assert_transfer_order_writable(order)
fields = _dump_transfer_body(body)
if 'transfer_attachments' in fields:
fields['transfer_attachments'] = validate_attachment_refs(
db,
fields['transfer_attachments'],
module='presign',
ctx=ctx,
creating=True,
business_id=order.id,
)
row = presign_crud.add_transfer(
db,
presign_order_id=order.id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
fields=fields,
create_dept_id=_current_dept_id(user_info),
commit=False,
)
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
db.commit()
return result
except Exception:
db.rollback()
raise
def update_presign_transfer(
db: Session,
transfer_id: str,
body: MarketPresignTransferBody,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
try:
current = _scoped_transfer(db, transfer_id, ctx, user_info)
if (current.pay_status or '').strip() != 'dfj':
raise ValueError('仅待缴费状态可编辑意向金转账笔')
fields = _dump_transfer_body(body)
if 'transfer_attachments' in fields:
fields['transfer_attachments'] = validate_attachment_refs(
db,
fields['transfer_attachments'],
module='presign',
ctx=ctx,
creating=False,
existing_refs=current.transfer_attachments,
business_id=current.presign_order_id,
)
row = presign_crud.update_transfer_draft(
db,
transfer_id=transfer_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
fields=fields,
commit=False,
)
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
db.commit()
return result
except Exception:
db.rollback()
raise
def delete_presign_transfer(
db: Session,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> None:
try:
row = _scoped_transfer(db, transfer_id, ctx, user_info)
if (row.pay_status or '').strip() != 'dfj':
raise ValueError('仅待缴费状态可删除意向金转账笔')
_update_transfer_if_status(
db,
transfer_id=transfer_id,
tenant_no=ctx.tenant_no,
expected_status='dfj',
values={
BizPresignTransfer.del_flag: 1,
BizPresignTransfer.update_by: ctx.user_id,
BizPresignTransfer.update_time: datetime.now(),
},
)
db.commit()
except Exception:
db.rollback()
raise
def submit_presign_transfer(
db: Session,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
try:
_scoped_transfer(db, transfer_id, ctx, user_info)
row = presign_crud.submit_receipt_finance(
db,
transfer_id=transfer_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
commit=False,
)
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
db.commit()
return result
except Exception:
db.rollback()
raise
def withdraw_presign_transfer(
db: Session,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
try:
_scoped_transfer(db, transfer_id, ctx, user_info)
row = presign_crud.withdraw_receipt(
db,
transfer_id=transfer_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
commit=False,
)
result = _mobile_transfer_to_dict(db, row, ctx.tenant_no)
db.commit()
return result
except Exception:
db.rollback()
raise
def convert_presign_order(
db: Session,
*,
order_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
try:
row = _scoped_order_for_update(db, order_id, ctx, user_info)
if (row.order_status or '').strip() not in ('', 'draft', 'active'):
raise ValueError('当前状态的预签约订单不可转正式协议')
presign_crud.validate_presign_person_ids(
db,
tenant_no=ctx.tenant_no,
collector_user_id=row.collector_user_id or ctx.user_id,
sales_user_id=row.sales_user_id,
region_manager_user_id=row.region_manager_user_id,
)
result = presign_crud.convert_to_protocol(
db,
order_id=order_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
create_dept_id=_current_dept_id(user_info),
)
if isinstance(result.get('presignOrder'), dict):
result = dict(result)
result['presignOrder'] = _mobile_order_to_dict(
db, row, ctx.tenant_no, with_children=True
)
return result
except Exception:
db.rollback()
raise