Files
zkxw/backend/app/schemas/market_mobile.py
T
zlwuandCursor 5e563a6669 fix: 子表保存改为就地同步,避免整批换 id 打断外键。
协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:51:53 +08:00

576 lines
22 KiB
Python

"""移动市场接口的白名单请求 DTO。"""
from __future__ import annotations
import re
from datetime import datetime
from decimal import Decimal
from typing import List, Literal, Optional
from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
condecimal,
constr,
field_validator,
)
MarketPaymentAmount = condecimal(
gt=Decimal('0'),
max_digits=18,
decimal_places=2,
allow_inf_nan=False,
)
MarketContractAmount = condecimal(
ge=Decimal('0'),
max_digits=18,
decimal_places=2,
allow_inf_nan=False,
)
MarketSampleTypeId = constr(min_length=1, max_length=64)
_CUSTOMER_INT_LABELS = {
'birth_order': '胎次',
'newborn_weight_gram': '新生儿体重',
}
def _blank_to_none(value):
"""移动端数字框清空后会提交 "",可选数字应视为未填。"""
if isinstance(value, str):
text = value.strip()
return None if text == '' else text
return value
def _optional_customer_int(value, field_name: str):
if value is None or isinstance(value, bool):
return value
if isinstance(value, int):
return value
label = _CUSTOMER_INT_LABELS[field_name]
if isinstance(value, float):
if value.is_integer():
return int(value)
raise ValueError(f'{label}须为整数')
if isinstance(value, str):
text = value.strip().replace(',', '')
if text == '':
return None
if re.fullmatch(r'[+-]?\d+\.?', text):
return int(text.rstrip('.'))
if re.fullmatch(r'[+-]?\d+\.\d+', text):
number = Decimal(text)
if number == number.to_integral_value():
return int(number)
raise ValueError(f'{label}须为整数')
return value
class MarketAttachmentDownloadBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
file_url: str = Field(..., min_length=1, max_length=1000, alias='fileUrl')
file_name: Optional[str] = Field(None, max_length=200, alias='fileName')
class MarketPresignTransferBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transfer_amount: Optional[MarketPaymentAmount] = Field(
None, alias="transferAmount"
)
transfer_time: Optional[str] = Field(None, alias="transferTime")
transfer_type: Optional[str] = Field(
None, max_length=16, alias="transferType"
)
transfer_attachments: Optional[str] = Field(
None, max_length=4000, alias="transferAttachments"
)
remark: Optional[str] = Field(None, max_length=512)
@field_validator('transfer_time')
@classmethod
def validate_transfer_time(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
if re.fullmatch(
r'(?:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'
r'|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}'
r'(?::\d{2}(?:\.\d{1,6})?)?)',
text,
) is None:
raise ValueError('转账时间格式无效')
try:
datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError('转账时间格式无效') from exc
return text
class MarketPresignOrderBody(BaseModel):
"""预签单可写字段;租户与财务字段不接受客户端输入。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
order_date: Optional[str] = Field(None, alias="orderDate")
protocol_no: Optional[str] = Field(
None, max_length=64, alias="protocolNo"
)
mother_name: Optional[str] = Field(
None, max_length=64, alias="motherName"
)
expected_delivery_date: Optional[str] = Field(
None, alias="expectedDeliveryDate"
)
region_id: Optional[str] = Field(
None, max_length=64, alias="regionId"
)
hospital_id: Optional[str] = Field(
None, max_length=64, alias="hospitalId"
)
sample_collect_time: Optional[str] = Field(None, alias="sampleCollectTime")
pay_mode: Optional[str] = Field(None, max_length=8, alias="payMode")
contract_amount: Optional[MarketContractAmount] = Field(
None, alias="contractAmount"
)
collector_user_id: Optional[str] = Field(
None, max_length=64, alias="collectorUserId"
)
region_manager_user_id: Optional[str] = Field(
None, max_length=64, alias="regionManagerUserId"
)
order_status: Optional[
Literal['draft', 'active', 'converted', 'void']
] = Field(None, alias='orderStatus')
remark: Optional[str] = Field(None, max_length=512)
sample_type_ids: Optional[List[MarketSampleTypeId]] = Field(
None, min_length=1, max_length=100, alias="sampleTypeIds"
)
transfers: Optional[List[MarketPresignTransferBody]] = None
@field_validator('order_date', 'expected_delivery_date')
@classmethod
def validate_ymd(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
if len(text) != 8 or not text.isdigit():
raise ValueError('日期必须使用 YYYYMMDD 格式')
try:
datetime.strptime(text, '%Y%m%d')
except ValueError as exc:
raise ValueError('日期必须是有效的 YYYYMMDD') from exc
return text
@field_validator('mother_name')
@classmethod
def validate_mother_name(cls, value: Optional[str]) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if not text:
raise ValueError('母亲姓名不能为空')
return text
@field_validator('sample_collect_time')
@classmethod
def validate_sample_collect_time(
cls, value: Optional[str]
) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
try:
datetime.fromisoformat(text.replace('Z', '+00:00'))
except ValueError as exc:
raise ValueError('采集时间格式无效') from exc
return text
class MarketPresignOrderCreateBody(MarketPresignOrderBody):
"""新增预签单必填字段;销售专员始终由服务端身份决定。"""
mother_name: str = Field(min_length=1, max_length=64, alias='motherName')
sample_type_ids: List[MarketSampleTypeId] = Field(
min_length=1, max_length=100, alias='sampleTypeIds'
)
class MarketProtocolCustomerBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
customer_id: Optional[str] = Field(None, alias='customerId')
father_customer_id: Optional[str] = Field(None, alias='fatherCustomerId')
newborn_customer_id: Optional[str] = Field(None, alias='newbornCustomerId')
mother_name: Optional[str] = Field(None, alias='motherName')
mother_phone: Optional[str] = Field(None, alias='motherPhone')
mother_id_card: Optional[str] = Field(None, alias='motherIdCard')
mother_birth_ym: Optional[str] = Field(None, alias='motherBirthYm')
mother_ethnic: Optional[str] = Field(None, alias='motherEthnic')
mother_email: Optional[str] = Field(None, alias='motherEmail')
mother_address: Optional[str] = Field(None, alias='motherAddress')
father_name: Optional[str] = Field(None, alias='fatherName')
father_phone: Optional[str] = Field(None, alias='fatherPhone')
father_id_card: Optional[str] = Field(None, alias='fatherIdCard')
newborn_name: Optional[str] = Field(None, alias='newbornName')
newborn_id_card: Optional[str] = Field(None, alias='newbornIdCard')
expected_delivery_date: Optional[str] = Field(None, alias='expectedDeliveryDate')
newborn_delivery_time: Optional[str] = Field(None, alias='newbornDeliveryTime')
newborn_sex: Optional[str] = Field(None, alias='newbornSex')
delivery_mode: Optional[str] = Field(None, alias='deliveryMode')
birth_order: Optional[int] = Field(None, alias='birthOrder')
newborn_weight_gram: Optional[int] = Field(None, alias='newbornWeightGram')
apgar_1min: Optional[float] = Field(None, alias='apgar1min')
apgar_5min: Optional[float] = Field(None, alias='apgar5min')
collector_user_id: Optional[str] = Field(None, alias='collectorUserId')
remark: Optional[str] = None
@field_validator('birth_order', 'newborn_weight_gram', mode='before')
@classmethod
def normalize_optional_int(cls, value, info):
return _optional_customer_int(value, info.field_name)
@field_validator('apgar_1min', 'apgar_5min', mode='before')
@classmethod
def normalize_optional_float(cls, value):
return _blank_to_none(value)
class MarketProtocolCollectionBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
region_id: Optional[str] = Field(None, alias='regionId')
hospital_id: Optional[str] = Field(None, alias='hospitalId')
medical_staff: Optional[str] = Field(None, alias='medicalStaff')
sales_user_id: Optional[str] = Field(None, alias='salesUserId')
region_manager_user_id: Optional[str] = Field(
None, alias='regionManagerUserId'
)
sample_collect_time: Optional[str] = Field(None, alias='sampleCollectTime')
maternal_blood_collect_time: Optional[str] = Field(
None, alias='maternalBloodCollectTime'
)
remark: Optional[str] = None
class MarketProtocolSampleFreightDraftBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
client_key: Optional[str] = Field(None, alias='clientKey')
transport_mode: Optional[str] = Field(None, alias='transportMode')
freight_amount: Optional[MarketContractAmount] = Field(None, alias='freightAmount')
waybill_no: Optional[str] = Field(None, alias='waybillNo')
class MarketProtocolSampleBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = Field(None, max_length=64, alias='sampleId')
sample_type_id: Optional[str] = Field(None, alias='sampleTypeId')
sample_code_id: Optional[str] = Field(None, alias='sampleCodeId')
sample_code: Optional[str] = Field(None, alias='sampleCode')
collect_amount: Optional[float] = Field(None, alias='collectAmount')
unit_id: Optional[str] = Field(None, alias='unitId')
price: Optional[MarketContractAmount] = None
original_price: Optional[MarketContractAmount] = Field(None, alias='originalPrice')
storage_years: Optional[int] = Field(None, alias='storageYears')
sample_status: Optional[str] = Field(None, alias='sampleStatus')
is_special: Optional[int] = Field(None, alias='isSpecial')
special_remark: Optional[str] = Field(None, alias='specialRemark')
remark: Optional[str] = None
eta_time: Optional[str] = Field(None, alias='etaTime')
freight_id: Optional[str] = Field(None, alias='freightId')
freight_draft: Optional[MarketProtocolSampleFreightDraftBody] = Field(
None, alias='freightDraft'
)
class MarketProtocolAttachmentsBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
protocol_attachments: Optional[str] = Field(
None, max_length=4000, alias='protocolAttachments'
)
consent_form_urls: Optional[str] = Field(
None, max_length=4000, alias='consentFormUrls'
)
health_survey_form_urls: Optional[str] = Field(
None, max_length=4000, alias='healthSurveyFormUrls'
)
attachment_urls: Optional[str] = Field(
None, max_length=4000, alias='attachmentUrls'
)
infectious_disease_report_urls: Optional[str] = Field(
None, max_length=4000, alias='infectiousDiseaseReportUrls'
)
class MarketProtocolNestedTransferBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = None
transfer_amount: Optional[MarketPaymentAmount] = Field(None, alias='transferAmount')
transfer_time: Optional[str] = Field(None, alias='transferTime')
transfer_type: Optional[str] = Field(None, max_length=16, alias='transferType')
transfer_attachments: Optional[str] = Field(None, max_length=4000, alias='transferAttachments')
remark: Optional[str] = Field(None, max_length=512)
class MarketProtocolPaymentScheduleBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = None
schedule_no: Optional[int] = Field(None, alias='scheduleNo')
due_amount: Optional[MarketContractAmount] = Field(None, alias='dueAmount')
due_date: Optional[str] = Field(None, alias='dueDate')
schedule_status: Optional[str] = Field(None, alias='scheduleStatus')
transfers: Optional[List[MarketProtocolNestedTransferBody]] = None
class MarketProtocolSamplePaymentBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = None
sample_code: str = Field(..., min_length=1, alias='sampleCode')
protocol_sample_id: Optional[str] = Field(None, alias='protocolSampleId')
pay_mode: str = Field(..., alias='payMode')
contract_amount: Optional[MarketContractAmount] = Field(None, alias='contractAmount')
lump_sum_deadline: Optional[str] = Field(None, alias='lumpSumDeadline')
installment_count: Optional[int] = Field(None, alias='installmentCount')
installment_interval_months: Optional[float] = Field(
None, alias='installmentIntervalMonths'
)
installment_start_date: Optional[str] = Field(
None, alias='installmentStartDate'
)
installment_advance_days: Optional[int] = Field(
None, alias='installmentAdvanceDays'
)
installment_ratios: Optional[List[MarketContractAmount]] = Field(
None, alias='installmentRatios'
)
remark: Optional[str] = None
schedules: Optional[List[MarketProtocolPaymentScheduleBody]] = None
class MarketProtocolPaymentBody(BaseModel):
"""历史 v1 整单缴费方案;不接收协议版本或财务状态字段。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
pay_mode: Literal['dj', 'fq', 'djfq', 'fgd'] = Field(..., alias='payMode')
contract_amount: Optional[MarketContractAmount] = Field(
None, alias='contractAmount'
)
lump_sum_deadline: Optional[str] = Field(None, max_length=8, alias='lumpSumDeadline')
installment_count: Optional[int] = Field(None, ge=1, alias='installmentCount')
installment_interval_months: Optional[Decimal] = Field(
None, ge=Decimal('0'), alias='installmentIntervalMonths'
)
installment_start_date: Optional[str] = Field(
None, max_length=8, alias='installmentStartDate'
)
installment_advance_days: Optional[int] = Field(
None, ge=0, le=365, alias='installmentAdvanceDays'
)
installment_ratios: Optional[List[MarketContractAmount]] = Field(
None, alias='installmentRatios'
)
@field_validator('lump_sum_deadline', 'installment_start_date')
@classmethod
def validate_ymd(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
if len(text) != 8 or not text.isdigit():
raise ValueError('日期必须使用 YYYYMMDD 格式')
try:
datetime.strptime(text, '%Y%m%d')
except ValueError as exc:
raise ValueError('日期必须是有效的 YYYYMMDD') from exc
return text
class MarketProtocolPaymentDetailBody(BaseModel):
"""历史 v1 缴费明细的市场可写字段白名单。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
id: Optional[str] = Field(None, max_length=64)
installment_no: Optional[int] = Field(None, ge=1, alias='installmentNo')
installment_due_date: Optional[str] = Field(
None, max_length=8, alias='installmentDueDate'
)
due_amount: Optional[MarketContractAmount] = Field(None, alias='dueAmount')
transfer_type: Optional[str] = Field(None, max_length=16, alias='transferType')
transfer_amount: Optional[MarketPaymentAmount] = Field(
None, alias='transferAmount'
)
transfer_time: Optional[str] = Field(None, alias='transferTime')
transfer_attachments: Optional[str] = Field(
None,
max_length=4000,
validation_alias=AliasChoices('transferAttachments', 'attachments'),
serialization_alias='transferAttachments',
)
remark: Optional[str] = Field(None, max_length=512)
@field_validator('installment_due_date')
@classmethod
def validate_ymd(cls, value: Optional[str]) -> Optional[str]:
if value in (None, ''):
return value
text = str(value).strip()
if len(text) != 8 or not text.isdigit():
raise ValueError('日期必须使用 YYYYMMDD 格式')
try:
datetime.strptime(text, '%Y%m%d')
except ValueError as exc:
raise ValueError('日期必须是有效的 YYYYMMDD') from exc
return text
class MarketProtocolBody(BaseModel):
"""移动协议白名单。协议/捐赠状态可改;审核、财务、退款及转换时间字段一律拒绝。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
source: Literal['cell_storage', 'research_donation']
protocol_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zky']
] = Field(None, alias='protocolStatus')
donation_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zckc']
] = Field(None, alias='donationStatus')
protocol_no: Optional[str] = Field(None, alias='protocolNo')
donation_no: Optional[str] = Field(None, alias='donationNo')
register_date: Optional[str] = Field(None, alias='registerDate')
donation_date: Optional[str] = Field(None, alias='donationDate')
collector_user_id: Optional[str] = Field(None, alias='collectorUserId')
protocol_total_amount: Optional[MarketContractAmount] = Field(
None, alias='protocolTotalAmount'
)
remark: Optional[str] = None
customer: Optional[MarketProtocolCustomerBody] = None
collection: Optional[MarketProtocolCollectionBody] = None
samples: Optional[List[MarketProtocolSampleBody]] = None
attachments: Optional[MarketProtocolAttachmentsBody] = None
sample_payments: Optional[List[MarketProtocolSamplePaymentBody]] = Field(
None, alias='samplePayments'
)
payment: Optional[MarketProtocolPaymentBody] = None
payment_details: Optional[List[MarketProtocolPaymentDetailBody]] = Field(
None, alias='paymentDetails'
)
@field_validator('register_date', 'donation_date')
@classmethod
def validate_protocol_ymd(cls, value: Optional[str]) -> Optional[str]:
"""接受 YYYYMMDD / YYYY-MM-DD,统一规范为 CHAR(8) YYYYMMDD。"""
if value in (None, ''):
return value
text = str(value).strip().replace('-', '').replace('/', '')
if len(text) != 8 or not text.isdigit():
raise ValueError('日期必须使用 YYYYMMDD 格式')
try:
datetime.strptime(text, '%Y%m%d')
except ValueError as exc:
raise ValueError('日期必须是有效的 YYYYMMDD') from exc
return text
class MarketProtocolAuditBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
audit_status: Literal['shtg', 'shth'] = Field(..., alias='auditStatus')
audit_remark: Optional[str] = Field(None, max_length=512, alias='auditRemark')
class MarketPaymentTransferBody(BaseModel):
"""市场侧仅可登记/编辑业务转账草稿,不接收状态及财务字段。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transfer_amount: Optional[MarketPaymentAmount] = Field(
None,
alias='transferAmount',
)
transfer_time: Optional[str] = Field(None, alias='transferTime')
transfer_type: Optional[str] = Field(None, max_length=16, alias='transferType')
transfer_attachments: Optional[str] = Field(
None, max_length=4000, alias='transferAttachments'
)
remark: Optional[str] = Field(None, max_length=512)
class MarketPaymentTransferCreateBody(MarketPaymentTransferBody):
schedule_id: str = Field(..., min_length=1, max_length=64, alias='scheduleId')
class MarketSampleHandleBody(BaseModel):
"""市场样本仅允许登记轻量办理信息。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
handle_time: Optional[str] = Field(None, alias='handleTime')
handler_user_id: Optional[str] = Field(
None, max_length=64, alias='handlerUserId'
)
attachments: Optional[str] = Field(None, max_length=4000)
remark: Optional[str] = Field(None, max_length=512)
class MarketSampleStorageCertBody(MarketSampleHandleBody):
pass
class MarketSampleInsuranceBody(MarketSampleHandleBody):
pass
class MarketFreightItemBody(BaseModel):
"""客户端只提交样本身份;协议与样本编码快照由服务端派生。"""
model_config = ConfigDict(extra='forbid', populate_by_name=True)
sample_source: Literal['cell_storage', 'research_donation'] = Field(
..., alias='sampleSource'
)
sample_id: str = Field(..., min_length=1, max_length=64, alias='sampleId')
class MarketFreightItemsBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
items: List[MarketFreightItemBody] = Field(..., min_length=1)
class MarketFreightCreateBody(MarketFreightItemsBody):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transport_mode: str = Field(..., min_length=1, max_length=100, alias='transportMode')
freight_amount: Optional[Decimal] = Field(
None, ge=Decimal('0'), max_digits=18, decimal_places=2,
allow_inf_nan=False, alias='freightAmount'
)
waybill_no: Optional[str] = Field(None, alias='waybillNo')
class MarketFreightUpdateBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
transport_mode: Optional[str] = Field(
None, min_length=1, max_length=100, alias='transportMode'
)
freight_amount: Optional[Decimal] = Field(
None, ge=Decimal('0'), max_digits=18, decimal_places=2,
allow_inf_nan=False, alias='freightAmount'
)
waybill_no: Optional[str] = Field(None, alias='waybillNo')