| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262 |
- """按样本缴费方案 / 应缴批次 / 转账笔 CRUD(payment_model_version=2)"""
- from __future__ import annotations
- import json
- from datetime import datetime
- from decimal import Decimal
- from typing import Any, Dict, List, Optional, Tuple
- from sqlalchemy import and_, exists, func, or_
- from sqlalchemy.orm import Session
- from app.core.utils import generate_uuid, parse_datetime
- from app.crud.collection_protocol import urls_to_attachment_list
- from app.models.models import (
- BizCellSampleType,
- BizCollectionProtocol,
- BizPresignTransfer,
- BizProtocolCollectionInfo,
- BizProtocolPartyCustomer,
- BizProtocolPaymentSchedule,
- BizProtocolPaymentTransfer,
- BizProtocolSample,
- BizProtocolSamplePayment,
- SysUser,
- )
- from app.services.payment_chase import (
- chase_period_money,
- is_chase_candidate,
- parse_due_date_ymd,
- )
- from app.services.protocol_transfer_status import (
- can_confirm_receipt,
- can_edit_transfer,
- can_submit_receipt,
- can_withdraw_receipt,
- )
- from app.services.sample_payment_fgd_convert import plan_fq_to_fgd_merge
- from app.services.sample_payment_period_summary import summarize_period
- from app.services.sample_payment_schedule_validate import schedule_dues_match_contract
- from app.services.sample_payment_progress import (
- compute_sample_payment_progress,
- is_schedule_effectively_cleared,
- )
- def _fmt_ymd8(v: Any) -> str:
- s = str(v or "").strip()
- if len(s) == 8 and s.isdigit():
- return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
- return s
- def _build_periods_array(
- schedules: List[BizProtocolPaymentSchedule],
- transfers: List[BizProtocolPaymentTransfer],
- ) -> List[Optional[Dict[str, Any]]]:
- """固定长度 6;无对应 schedule_no 的槽位为 None。"""
- by_sch: Dict[str, List[BizProtocolPaymentTransfer]] = {}
- for t in transfers:
- by_sch.setdefault(t.schedule_id or "", []).append(t)
- by_no: Dict[int, BizProtocolPaymentSchedule] = {}
- for s in schedules:
- if (s.schedule_status or "") == "void":
- continue
- no = int(s.schedule_no or 0)
- if 1 <= no <= 6:
- by_no[no] = s
- periods: List[Optional[Dict[str, Any]]] = []
- for no in range(1, 7):
- s = by_no.get(no)
- if not s:
- periods.append(None)
- continue
- trs = by_sch.get(s.id, [])
- summary = summarize_period(
- due_amount=s.due_amount,
- transfers=[
- {
- "transferAmount": t.transfer_amount,
- "transferType": t.transfer_type or "",
- "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
- if t.transfer_time
- else "",
- "payStatus": t.pay_status or "",
- }
- for t in trs
- ],
- )
- periods.append(
- {
- "scheduleId": s.id,
- "scheduleNo": no,
- "dueAmount": float(s.due_amount) if s.due_amount is not None else None,
- "dueDate": _fmt_ymd8(s.due_date),
- "displayPaidAmount": float(summary["displayPaidAmount"]),
- "arrearsAmount": float(summary["arrearsAmount"]),
- "payDate": summary.get("payDate") or "",
- "payTypeSummary": summary.get("payTypeSummary") or "",
- "financeAudit": summary.get("financeAudit") or "dfj",
- }
- )
- return periods
- def _dec(v: Any) -> Optional[Decimal]:
- if v is None or v == "":
- return None
- return Decimal(str(v))
- def _now() -> datetime:
- return datetime.now()
- def _get_sample_payment_or_raise(
- db: Session, *, sample_payment_id: str, tenant_no: str
- ) -> BizProtocolSamplePayment:
- obj = (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.id == sample_payment_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- )
- .first()
- )
- if not obj:
- raise ValueError("样本缴费方案不存在")
- return obj
- def _lock_sample_payment_graph(
- db: Session,
- *,
- sample_payment_id: str,
- tenant_no: str,
- ) -> Tuple[
- BizProtocolSamplePayment,
- List[BizProtocolPaymentSchedule],
- List[BizProtocolPaymentTransfer],
- ]:
- """按方案→批次→转账顺序加行锁,供普通方案保存做最终一致性校验。"""
- sp = (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.id == sample_payment_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- )
- .populate_existing()
- .with_for_update()
- .first()
- )
- if not sp:
- raise ValueError("样本缴费方案不存在")
- schedules = (
- db.query(BizProtocolPaymentSchedule)
- .filter(
- BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- .order_by(BizProtocolPaymentSchedule.schedule_no.asc())
- .populate_existing()
- .with_for_update()
- .all()
- )
- schedule_ids = [row.id for row in schedules]
- transfers: List[BizProtocolPaymentTransfer] = []
- if schedule_ids:
- transfers = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.schedule_id.in_(schedule_ids),
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .order_by(BizProtocolPaymentTransfer.id.asc())
- .populate_existing()
- .with_for_update()
- .all()
- )
- return sp, schedules, transfers
- def _locked_schedule_ids(
- schedules: List[BizProtocolPaymentSchedule],
- transfers: List[BizProtocolPaymentTransfer],
- ) -> set:
- locked = {
- row.id
- for row in schedules
- if (row.schedule_status or "").strip() != "open"
- }
- locked.update(
- row.schedule_id
- for row in transfers
- if (row.pay_status or "").strip() != "dfj"
- )
- return locked
- def _assert_locked_schedule_structure(
- schedules: List[BizProtocolPaymentSchedule],
- transfers: List[BizProtocolPaymentTransfer],
- payload: List[Dict[str, Any]],
- ) -> None:
- locked_ids = _locked_schedule_ids(schedules, transfers)
- if not locked_ids:
- return
- current_nos = {int(row.schedule_no or 0) for row in schedules}
- submitted_no_list = [
- _schedule_no_of(raw, idx + 1)
- for idx, raw in enumerate(payload or [])
- if isinstance(raw, dict)
- ]
- if len(submitted_no_list) != len(set(submitted_no_list)):
- raise ValueError("应缴批次期号重复")
- submitted_nos = set(submitted_no_list)
- locked_nos = {
- int(row.schedule_no or 0) for row in schedules if row.id in locked_ids
- }
- if submitted_nos != current_nos or not locked_nos.issubset(submitted_nos):
- raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
- by_id = {row.id: int(row.schedule_no or 0) for row in schedules}
- for idx, raw in enumerate(payload or []):
- if not isinstance(raw, dict):
- continue
- schedule_id = str(raw.get("id") or raw.get("scheduleId") or "").strip()
- if schedule_id in locked_ids and _schedule_no_of(raw, idx + 1) != by_id[schedule_id]:
- raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
- def _get_schedule_or_raise(
- db: Session, *, schedule_id: str, tenant_no: str
- ) -> BizProtocolPaymentSchedule:
- obj = (
- db.query(BizProtocolPaymentSchedule)
- .filter(
- BizProtocolPaymentSchedule.id == schedule_id,
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- .first()
- )
- if not obj:
- raise ValueError("应缴批次不存在")
- return obj
- def _get_transfer_or_raise(
- db: Session, *, transfer_id: str, tenant_no: str
- ) -> BizProtocolPaymentTransfer:
- obj = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.id == transfer_id,
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .first()
- )
- if not obj:
- raise ValueError("转账笔不存在")
- return obj
- def _get_transfer_with_schedule_lock(
- db: Session,
- *,
- transfer_id: str,
- tenant_no: str,
- user_info: Optional[dict] = None,
- ) -> BizProtocolPaymentTransfer:
- """Authorize the complete parent chain, then lock schedule before transfer."""
- parent = (
- db.query(
- BizProtocolPaymentTransfer.schedule_id,
- BizProtocolPaymentTransfer.sample_payment_id,
- BizProtocolPaymentTransfer.collection_protocol_id,
- )
- .filter(
- BizProtocolPaymentTransfer.id == transfer_id,
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .first()
- )
- if not parent:
- if user_info is not None:
- raise PermissionError("无权操作该协议")
- raise ValueError("转账笔不存在")
- schedule_id = parent.schedule_id
- schedule_query = (
- db.query(BizProtocolPaymentSchedule)
- .join(
- BizProtocolSamplePayment,
- and_(
- BizProtocolSamplePayment.id == parent.sample_payment_id,
- BizProtocolSamplePayment.id
- == BizProtocolPaymentSchedule.sample_payment_id,
- BizProtocolSamplePayment.collection_protocol_id
- == parent.collection_protocol_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- ),
- )
- .join(
- BizCollectionProtocol,
- and_(
- BizCollectionProtocol.id == parent.collection_protocol_id,
- BizCollectionProtocol.id
- == BizProtocolPaymentSchedule.collection_protocol_id,
- BizCollectionProtocol.tenant_no == tenant_no,
- BizCollectionProtocol.del_flag == 0,
- ),
- )
- .filter(
- BizProtocolPaymentSchedule.id == schedule_id,
- BizProtocolPaymentSchedule.sample_payment_id
- == parent.sample_payment_id,
- BizProtocolPaymentSchedule.collection_protocol_id
- == parent.collection_protocol_id,
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- )
- if user_info is not None:
- from app.core.data_scope import apply_data_scope
- schedule_query = apply_data_scope(
- schedule_query,
- BizCollectionProtocol,
- user_info,
- db,
- tenant_no,
- )
- schedule = schedule_query.populate_existing().with_for_update().first()
- if not schedule:
- if user_info is not None:
- raise PermissionError("无权操作该协议")
- raise ValueError("应缴批次不存在")
- transfer = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.id == transfer_id,
- BizProtocolPaymentTransfer.schedule_id == schedule_id,
- BizProtocolPaymentTransfer.sample_payment_id
- == parent.sample_payment_id,
- BizProtocolPaymentTransfer.collection_protocol_id
- == parent.collection_protocol_id,
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .populate_existing()
- .with_for_update()
- .first()
- )
- if not transfer:
- raise ValueError("转账笔不存在")
- return transfer
- def has_blocking_transfer_for_sample_payment(
- db: Session, *, sample_payment_id: str, tenant_no: str
- ) -> bool:
- row = (
- db.query(BizProtocolPaymentTransfer.id)
- .filter(
- BizProtocolPaymentTransfer.sample_payment_id == sample_payment_id,
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- BizProtocolPaymentTransfer.pay_status == "jfqrz",
- )
- .first()
- )
- return row is not None
- def assert_sample_payment_structure_editable(
- db: Session, *, sample_payment_id: str, tenant_no: str
- ) -> None:
- """已废弃:付款确认中不再阻断协议保存/改方案。保留空实现以兼容旧调用。"""
- return
- # 兼容旧调用名
- assert_sample_payment_editable = assert_sample_payment_structure_editable
- def find_active_sample_payment(
- db: Session,
- *,
- tenant_no: str,
- sample_payment_id: Optional[str] = None,
- collection_protocol_id: Optional[str] = None,
- sample_code: Optional[str] = None,
- ) -> Optional[BizProtocolSamplePayment]:
- if sample_payment_id:
- query = db.query(BizProtocolSamplePayment).filter(
- BizProtocolSamplePayment.id == sample_payment_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- )
- if collection_protocol_id:
- query = query.filter(
- BizProtocolSamplePayment.collection_protocol_id
- == collection_protocol_id
- )
- if sample_code:
- query = query.filter(
- BizProtocolSamplePayment.sample_code == sample_code.strip()
- )
- return query.first()
- code = (sample_code or "").strip()
- pid = (collection_protocol_id or "").strip()
- if not code or not pid:
- return None
- return (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.collection_protocol_id == pid,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.sample_code == code,
- BizProtocolSamplePayment.del_flag == 0,
- )
- .first()
- )
- def sample_payment_has_yjf(
- db: Session, *, sample_payment_id: str, tenant_no: str
- ) -> bool:
- """样本缴费方案下是否存在已财务确认(yjf)转账。"""
- q = (
- db.query(BizProtocolPaymentTransfer.id)
- .filter(
- BizProtocolPaymentTransfer.sample_payment_id == sample_payment_id,
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- BizProtocolPaymentTransfer.pay_status == "yjf",
- )
- .limit(1)
- .first()
- )
- return q is not None
- def apply_fq_to_fgd_merge(
- db: Session,
- *,
- sample_payment_id: str,
- tenant_no: str,
- user_id: Optional[str],
- target_pay_mode: str = "fgd",
- ) -> Dict[str, Any]:
- """将分期方案并期为趸交/分改趸:更新目标期应缴、软删后续期及其 dfj 转账。"""
- sp = _get_sample_payment_or_raise(
- db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
- )
- schedules = (
- db.query(BizProtocolPaymentSchedule)
- .filter(
- BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- .order_by(BizProtocolPaymentSchedule.schedule_no.asc())
- .all()
- )
- sch_ids = [s.id for s in schedules]
- transfers = []
- if sch_ids:
- transfers = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.schedule_id.in_(sch_ids),
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .all()
- )
- sch_dicts = [
- {
- "id": s.id,
- "scheduleNo": s.schedule_no,
- "dueAmount": s.due_amount,
- "scheduleStatus": s.schedule_status or "open",
- }
- for s in schedules
- ]
- tr_dicts = [
- {
- "scheduleId": t.schedule_id,
- "payStatus": t.pay_status or "",
- "transferAmount": t.transfer_amount,
- }
- for t in transfers
- ]
- plan = plan_fq_to_fgd_merge(schedules=sch_dicts, transfers=tr_dicts)
- now = _now()
- target = next((s for s in schedules if s.id == plan["targetScheduleId"]), None)
- if target is None:
- raise ValueError("并期目标期次不存在")
- target.due_amount = plan["newDueAmount"]
- target.update_by = user_id
- target.update_time = now
- db.add(target)
- soft_ids = set(plan["softDeleteScheduleIds"] or [])
- for s in schedules:
- if s.id not in soft_ids:
- continue
- s.del_flag = 1
- s.update_by = user_id
- s.update_time = now
- db.add(s)
- if soft_ids:
- for t in transfers:
- if t.schedule_id in soft_ids and (t.pay_status or "") == "dfj":
- t.del_flag = 1
- t.update_by = user_id
- t.update_time = now
- db.add(t)
- mode = (target_pay_mode or "fgd").strip()
- if mode not in ("dj", "fgd"):
- mode = "fgd"
- sp.pay_mode = mode
- sp.installment_count = plan["newInstallmentCount"]
- sp.update_by = user_id
- sp.update_time = now
- db.add(sp)
- db.flush()
- return plan
- def upsert_sample_payment(
- db: Session,
- *,
- collection_protocol_id: str,
- tenant_no: str,
- user_id: Optional[str],
- sample_code: str,
- pay_mode: str,
- contract_amount: Any = None,
- protocol_sample_id: Optional[str] = None,
- installment_count: Optional[int] = None,
- installment_interval_months: Any = None,
- installment_start_date: Optional[str] = None,
- installment_advance_days: Optional[int] = None,
- installment_ratios: Optional[str] = None,
- lump_sum_deadline: Optional[str] = None,
- remark: Optional[str] = None,
- sample_payment_id: Optional[str] = None,
- ) -> BizProtocolSamplePayment:
- code = (sample_code or "").strip()
- if not code:
- raise ValueError("样本编码不能为空")
- mode = (pay_mode or "").strip()
- if mode not in ("dj", "fq", "djfq", "fgd"):
- raise ValueError("缴费模式无效")
- obj: Optional[BizProtocolSamplePayment] = None
- if sample_payment_id:
- obj = find_active_sample_payment(
- db,
- sample_payment_id=sample_payment_id,
- collection_protocol_id=collection_protocol_id,
- sample_code=code,
- tenant_no=tenant_no,
- )
- if obj is None:
- raise ValueError("样本缴费方案不存在或不属于当前协议样本")
- else:
- obj = (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.sample_code == code,
- BizProtocolSamplePayment.del_flag == 0,
- )
- .first()
- )
- now = _now()
- if obj:
- obj.protocol_sample_id = (protocol_sample_id or "").strip() or None
- obj.sample_code = code
- obj.pay_mode = mode
- obj.contract_amount = _dec(contract_amount)
- obj.installment_count = installment_count
- obj.installment_interval_months = _dec(installment_interval_months)
- obj.installment_start_date = (installment_start_date or "").strip() or None
- obj.installment_advance_days = installment_advance_days
- obj.installment_ratios = (installment_ratios or "").strip() or None
- obj.lump_sum_deadline = (lump_sum_deadline or "").strip() or None
- if remark is not None:
- obj.remark = (remark or "").strip() or None
- obj.update_by = user_id
- obj.update_time = now
- db.add(obj)
- db.flush()
- return obj
- obj = BizProtocolSamplePayment(
- id=generate_uuid(),
- collection_protocol_id=collection_protocol_id,
- protocol_sample_id=(protocol_sample_id or "").strip() or None,
- sample_code=code,
- pay_mode=mode,
- contract_amount=_dec(contract_amount),
- installment_count=installment_count,
- installment_interval_months=_dec(installment_interval_months),
- installment_start_date=(installment_start_date or "").strip() or None,
- installment_advance_days=installment_advance_days,
- installment_ratios=(installment_ratios or "").strip() or None,
- lump_sum_deadline=(lump_sum_deadline or "").strip() or None,
- tenant_no=tenant_no,
- order_num=0,
- remark=(remark or "").strip() or 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()
- return obj
- def _schedule_no_of(raw: Dict[str, Any], fallback: int) -> int:
- v = raw.get("schedule_no")
- if v is None:
- v = raw.get("scheduleNo")
- if v is None:
- return fallback
- try:
- return int(v)
- except (TypeError, ValueError):
- return fallback
- def _apply_transfer_editable_fields(
- t: BizProtocolPaymentTransfer, raw: Dict[str, Any]
- ) -> None:
- """更新转账可编辑字段;不改 pay_status。"""
- if "transfer_type" in raw or "transferType" in raw:
- v = raw.get("transfer_type", raw.get("transferType"))
- t.transfer_type = (str(v) if v is not None else "").strip() or None
- if "transfer_amount" in raw or "transferAmount" in raw:
- amt = _dec(raw.get("transfer_amount", raw.get("transferAmount")))
- if amt is not None and amt < 0:
- raise ValueError("转账金额无效")
- t.transfer_amount = amt
- if "transfer_time" in raw or "transferTime" in raw:
- v = raw.get("transfer_time", raw.get("transferTime"))
- if v in (None, ""):
- t.transfer_time = None
- elif isinstance(v, datetime):
- t.transfer_time = v
- else:
- t.transfer_time = parse_datetime(str(v))
- if "transfer_attachments" in raw or "transferAttachments" in raw:
- v = raw.get("transfer_attachments", raw.get("transferAttachments"))
- t.transfer_attachments = (str(v) if v is not None else "").strip() or None
- if "remark" in raw:
- t.remark = (raw.get("remark") or "").strip() or None
- def _sync_transfers_for_schedule(
- db: Session,
- *,
- sch: BizProtocolPaymentSchedule,
- tenant_no: str,
- user_id: Optional[str],
- transfers: List[Dict[str, Any]],
- ) -> None:
- now = _now()
- existing = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.schedule_id == sch.id,
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .order_by(BizProtocolPaymentTransfer.id.asc())
- .populate_existing()
- .with_for_update()
- .all()
- )
- by_id = {t.id: t for t in existing}
- keep_ids: set = set()
- for raw in transfers or []:
- if not isinstance(raw, dict):
- continue
- tid = (raw.get("id") or "").strip()
- if tid and tid in by_id:
- t = by_id[tid]
- keep_ids.add(tid)
- if (t.pay_status or "").strip() != "dfj":
- # 非草稿转账由专用状态流转接口维护,普通方案保存整行只读。
- continue
- _apply_transfer_editable_fields(t, raw)
- t.update_by = user_id
- t.update_time = now
- db.add(t)
- continue
- if tid:
- raise ValueError("转账笔不存在或不属于当前批次")
- # 新增转账笔(始终以待缴费入库;提交财务走独立接口)
- add_transfer(
- db,
- schedule_id=sch.id,
- tenant_no=tenant_no,
- user_id=user_id,
- transfer_amount=raw.get("transfer_amount", raw.get("transferAmount")),
- transfer_time=raw.get("transfer_time", raw.get("transferTime")),
- transfer_type=raw.get("transfer_type", raw.get("transferType")),
- transfer_attachments=raw.get(
- "transfer_attachments", raw.get("transferAttachments")
- ),
- remark=raw.get("remark"),
- commit=False,
- )
- for tid, t in by_id.items():
- if tid in keep_ids:
- continue
- if (t.pay_status or "").strip() != "dfj":
- continue
- t.del_flag = 1
- t.update_by = user_id
- t.update_time = now
- db.add(t)
- def replace_schedules(
- db: Session,
- *,
- sample_payment_id: str,
- tenant_no: str,
- user_id: Optional[str],
- schedules: List[Dict[str, Any]],
- ) -> List[BizProtocolPaymentSchedule]:
- """仅在全部批次为 open、全部转账为 dfj 时重建批次。"""
- sp, old_schedules, transfers = _lock_sample_payment_graph(
- db,
- sample_payment_id=sample_payment_id,
- tenant_no=tenant_no,
- )
- if _locked_schedule_ids(old_schedules, transfers):
- raise ValueError("存在锁定批次,不能省略、删除、重编号或重建")
- now = _now()
- if old_schedules:
- for t in transfers:
- t.del_flag = 1
- t.update_by = user_id
- t.update_time = now
- db.add(t)
- for s in old_schedules:
- s.del_flag = 1
- s.update_by = user_id
- s.update_time = now
- db.add(s)
- created: List[BizProtocolPaymentSchedule] = []
- for idx, raw in enumerate(schedules or []):
- due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
- schedule_no = _schedule_no_of(raw, idx + 1)
- obj = BizProtocolPaymentSchedule(
- id=generate_uuid(),
- sample_payment_id=sp.id,
- collection_protocol_id=sp.collection_protocol_id,
- sample_code=sp.sample_code,
- schedule_no=schedule_no,
- due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
- due_date=due_date,
- schedule_status="open",
- tenant_no=tenant_no,
- order_num=idx,
- remark=(raw.get("remark") or "").strip() or None,
- create_by=user_id,
- create_dept_id="",
- create_time=now,
- update_by=user_id,
- update_time=now,
- del_flag=0,
- )
- db.add(obj)
- created.append(obj)
- db.flush()
- return created
- def sync_schedules_and_transfers(
- db: Session,
- *,
- sample_payment_id: str,
- tenant_no: str,
- user_id: Optional[str],
- schedules: List[Dict[str, Any]],
- ) -> List[BizProtocolPaymentSchedule]:
- """
- 同步应缴批次与转账笔:
- - 有非 open 批次或非 dfj 转账:结构固定,状态与非草稿转账只读保留
- - 全部为 open + dfj:允许重建批次,旧草稿转账随旧批次软删
- """
- _, old_schedules, locked_transfers = _lock_sample_payment_graph(
- db,
- sample_payment_id=sample_payment_id,
- tenant_no=tenant_no,
- )
- now = _now()
- by_no = {int(s.schedule_no or 0): s for s in old_schedules}
- payload = list(schedules or [])
- _assert_locked_schedule_structure(old_schedules, locked_transfers, payload)
- new_nos: List[int] = []
- for idx, raw in enumerate(payload):
- if not isinstance(raw, dict):
- continue
- new_nos.append(_schedule_no_of(raw, idx + 1))
- old_nos = set(by_no.keys())
- new_nos_set = set(new_nos)
- structure_changed = old_nos != new_nos_set
- if structure_changed:
- created = replace_schedules(
- db,
- sample_payment_id=sample_payment_id,
- tenant_no=tenant_no,
- user_id=user_id,
- schedules=payload,
- )
- for i, sch_raw in enumerate(payload):
- if i >= len(created) or not isinstance(sch_raw, dict):
- continue
- _sync_transfers_for_schedule(
- db,
- sch=created[i],
- tenant_no=tenant_no,
- user_id=user_id,
- transfers=sch_raw.get("transfers") or [],
- )
- return created
- result: List[BizProtocolPaymentSchedule] = []
- for idx, raw in enumerate(payload):
- if not isinstance(raw, dict):
- continue
- sno = _schedule_no_of(raw, idx + 1)
- sch = by_no.get(sno)
- if not sch:
- raise ValueError("应缴批次数据不一致,请刷新后重试")
- due_date = (raw.get("due_date") or raw.get("dueDate") or "").strip() or None
- sch.due_amount = _dec(
- raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
- )
- sch.due_date = due_date
- sch.order_num = idx
- if "remark" in raw:
- sch.remark = (raw.get("remark") or "").strip() or None
- sch.update_by = user_id
- sch.update_time = now
- db.add(sch)
- _sync_transfers_for_schedule(
- db,
- sch=sch,
- tenant_no=tenant_no,
- user_id=user_id,
- transfers=raw.get("transfers") or [],
- )
- result.append(sch)
- db.flush()
- return result
- def add_transfer(
- db: Session,
- *,
- schedule_id: str,
- tenant_no: str,
- user_id: Optional[str],
- transfer_amount: Any = None,
- transfer_time: Any = None,
- transfer_type: Optional[str] = None,
- transfer_attachments: Optional[str] = None,
- remark: Optional[str] = None,
- create_dept_id: str = "",
- commit: bool = True,
- ) -> BizProtocolPaymentTransfer:
- sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no)
- now = _now()
- tt = None
- if transfer_time not in (None, ""):
- tt = (
- transfer_time
- if isinstance(transfer_time, datetime)
- else parse_datetime(str(transfer_time))
- )
- obj = BizProtocolPaymentTransfer(
- id=generate_uuid(),
- schedule_id=sch.id,
- sample_payment_id=sch.sample_payment_id,
- collection_protocol_id=sch.collection_protocol_id,
- sample_code=sch.sample_code,
- transfer_amount=_dec(transfer_amount),
- transfer_time=tt,
- transfer_type=(transfer_type or "").strip() or None,
- transfer_attachments=(transfer_attachments or "").strip() or None,
- pay_status="dfj",
- tenant_no=tenant_no,
- order_num=0,
- remark=(remark or "").strip() or None,
- create_by=user_id,
- create_dept_id=(create_dept_id or "").strip(),
- create_time=now,
- update_by=user_id,
- update_time=now,
- del_flag=0,
- )
- db.add(obj)
- if commit:
- db.commit()
- db.refresh(obj)
- else:
- db.flush()
- return obj
- def update_transfer_draft(
- db: Session,
- *,
- transfer_id: str,
- tenant_no: str,
- user_id: Optional[str],
- fields: Dict[str, Any],
- locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
- ) -> BizProtocolPaymentTransfer:
- t = locked_transfer or _get_transfer_or_raise(
- db, transfer_id=transfer_id, tenant_no=tenant_no
- )
- if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
- raise ValueError("转账笔不存在")
- if not can_edit_transfer(t.pay_status):
- raise ValueError("财务已确认收款的转账笔不可编辑")
- _apply_transfer_editable_fields(t, fields)
- t.update_by = user_id
- t.update_time = _now()
- db.add(t)
- db.commit()
- db.refresh(t)
- return t
- def _validate_transfer_submit_ready(t: BizProtocolPaymentTransfer) -> None:
- if not (t.transfer_type or "").strip():
- raise ValueError("请选择转账方式")
- if t.transfer_amount is None or Decimal(str(t.transfer_amount)) <= 0:
- raise ValueError("请填写转账金额")
- def submit_receipt_finance(
- db: Session,
- *,
- transfer_id: str,
- tenant_no: str,
- user_id: Optional[str],
- draft_fields: Optional[Dict[str, Any]] = None,
- locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
- ) -> BizProtocolPaymentTransfer:
- t = locked_transfer or _get_transfer_or_raise(
- db, transfer_id=transfer_id, tenant_no=tenant_no
- )
- if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
- raise ValueError("转账笔不存在")
- if draft_fields:
- if t.pay_status != "dfj":
- raise ValueError("仅已付款状态可编辑转账笔")
- # 就地改草稿字段(不单独 commit)
- if "transfer_type" in draft_fields or "transferType" in draft_fields:
- v = draft_fields.get("transfer_type", draft_fields.get("transferType"))
- t.transfer_type = (str(v) if v is not None else "").strip() or None
- if "transfer_amount" in draft_fields or "transferAmount" in draft_fields:
- t.transfer_amount = _dec(
- draft_fields.get("transfer_amount", draft_fields.get("transferAmount"))
- )
- if "transfer_time" in draft_fields or "transferTime" in draft_fields:
- v = draft_fields.get("transfer_time", draft_fields.get("transferTime"))
- if v in (None, ""):
- t.transfer_time = None
- elif isinstance(v, datetime):
- t.transfer_time = v
- else:
- t.transfer_time = parse_datetime(str(v))
- if "transfer_attachments" in draft_fields or "transferAttachments" in draft_fields:
- v = draft_fields.get(
- "transfer_attachments", draft_fields.get("transferAttachments")
- )
- t.transfer_attachments = (str(v) if v is not None else "").strip() or None
- if not can_submit_receipt(t.pay_status):
- raise ValueError("仅已付款状态可提交财务确认")
- _validate_transfer_submit_ready(t)
- t.pay_status = "jfqrz"
- t.update_by = user_id
- t.update_time = _now()
- db.add(t)
- db.commit()
- db.refresh(t)
- return t
- def confirm_receipt(
- db: Session,
- *,
- transfer_id: str,
- tenant_no: str,
- user_id: Optional[str],
- finance_confirm_attachments: Optional[str] = None,
- finance_remark: Optional[str] = None,
- locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
- ) -> BizProtocolPaymentTransfer:
- t = locked_transfer or _get_transfer_with_schedule_lock(
- db, transfer_id=transfer_id, tenant_no=tenant_no
- )
- if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
- raise ValueError("转账笔不存在")
- if t.pay_status == "yjf":
- raise ValueError("该转账笔已确认收款")
- if not can_confirm_receipt(t.pay_status):
- raise ValueError("当前状态不可确认收款")
- t.pay_status = "yjf"
- if finance_confirm_attachments is not None:
- t.finance_confirm_attachments = finance_confirm_attachments.strip() or None
- if finance_remark is not None:
- t.finance_remark = finance_remark.strip() or None
- t.update_by = user_id
- t.update_time = _now()
- db.add(t)
- # 批次已在 _get_transfer_with_schedule_lock 中加锁,此处不再二次 for_update
- schedule = (
- db.query(BizProtocolPaymentSchedule)
- .filter(
- BizProtocolPaymentSchedule.id == t.schedule_id,
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- .first()
- )
- _sync_schedule_cleared_after_confirm(
- db,
- schedule=schedule,
- tenant_no=tenant_no,
- user_id=user_id,
- )
- db.commit()
- db.refresh(t)
- return t
- def _sync_schedule_cleared_after_confirm(
- db: Session,
- *,
- schedule: Optional[BizProtocolPaymentSchedule],
- tenant_no: str,
- user_id: Optional[str],
- ) -> None:
- """财务确认后:实缴覆盖应缴则回写 schedule_status=cleared(调用方须已持有批次锁)。"""
- if not schedule:
- return
- status = (schedule.schedule_status or "").strip()
- if status in ("cleared", "void"):
- return
- confirmed = (
- db.query(func.coalesce(func.sum(BizProtocolPaymentTransfer.transfer_amount), 0))
- .filter(
- BizProtocolPaymentTransfer.schedule_id == schedule.id,
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- BizProtocolPaymentTransfer.pay_status == "yjf",
- )
- .scalar()
- or Decimal("0")
- )
- due = Decimal(str(schedule.due_amount or 0))
- if Decimal(str(confirmed)) < due:
- return
- schedule.schedule_status = "cleared"
- schedule.update_by = user_id
- schedule.update_time = _now()
- db.add(schedule)
- def reject_receipt(
- db: Session,
- *,
- transfer_id: str,
- tenant_no: str,
- user_id: Optional[str],
- locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
- ) -> BizProtocolPaymentTransfer:
- t = locked_transfer or _get_transfer_with_schedule_lock(
- db, transfer_id=transfer_id, tenant_no=tenant_no
- )
- if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
- raise ValueError("转账笔不存在")
- if t.pay_status != "jfqrz":
- raise ValueError("当前状态不可退回")
- t.pay_status = "dfj"
- t.update_by = user_id
- t.update_time = _now()
- db.add(t)
- db.commit()
- db.refresh(t)
- return t
- def withdraw_receipt(
- db: Session,
- *,
- transfer_id: str,
- tenant_no: str,
- user_id: Optional[str],
- locked_transfer: Optional[BizProtocolPaymentTransfer] = None,
- ) -> BizProtocolPaymentTransfer:
- """业务撤回:jfqrz → dfj,保留 finance_remark / finance_confirm_attachments。"""
- t = locked_transfer or _get_transfer_with_schedule_lock(
- db, transfer_id=transfer_id, tenant_no=tenant_no
- )
- if t.id != transfer_id or t.tenant_no != tenant_no or t.del_flag != 0:
- raise ValueError("转账笔不存在")
- if not can_withdraw_receipt(t.pay_status):
- raise ValueError("仅付款确认中可撤回")
- t.pay_status = "dfj"
- t.update_by = user_id
- t.update_time = _now()
- db.add(t)
- db.commit()
- db.refresh(t)
- return t
- def soft_delete_payment_transfer(
- db: Session,
- *,
- transfer_id: str,
- tenant_no: str,
- user_id: Optional[str],
- ) -> None:
- t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
- if t.pay_status != "dfj":
- raise ValueError("仅已付款状态转账笔可删除")
- t.del_flag = 1
- t.update_by = user_id
- t.update_time = _now()
- db.add(t)
- db.commit()
- def _money_value(value: Any, as_string: bool = False) -> Any:
- if value is None:
- return None
- amount = Decimal(str(value)).quantize(Decimal('0.01'))
- return format(amount, 'f') if as_string else float(amount)
- def _transfer_to_dict(
- db: Session,
- t: BizProtocolPaymentTransfer,
- tenant_no: str,
- money_as_string: bool = False,
- ) -> Dict[str, Any]:
- return {
- "id": t.id,
- "scheduleId": t.schedule_id,
- "samplePaymentId": t.sample_payment_id,
- "collectionProtocolId": t.collection_protocol_id,
- "sampleCode": t.sample_code or "",
- "transferAmount": _money_value(t.transfer_amount, money_as_string),
- "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if t.transfer_time else "",
- "transferType": t.transfer_type or "",
- "transferAttachments": t.transfer_attachments or "",
- "attachments": urls_to_attachment_list(t.transfer_attachments, db, tenant_no),
- "payStatus": t.pay_status or "",
- "financeConfirmAttachments": t.finance_confirm_attachments or "",
- "financeConfirmAttachmentFiles": urls_to_attachment_list(
- t.finance_confirm_attachments, db, tenant_no
- ),
- "financeRemark": t.finance_remark or "",
- "remark": t.remark or "",
- "source": "formal",
- "createTime": t.create_time.strftime("%Y-%m-%d %H:%M:%S") if t.create_time else "",
- "updateTime": t.update_time.strftime("%Y-%m-%d %H:%M:%S") if t.update_time else "",
- }
- def _schedule_to_dict(
- db: Session,
- s: BizProtocolPaymentSchedule,
- transfers: List[BizProtocolPaymentTransfer],
- tenant_no: str,
- money_as_string: bool = False,
- ) -> Dict[str, Any]:
- # 展示口径:已缴仅计 yjf(与 summarize_period / chase_period_money 一致)
- paid, arrears = chase_period_money(
- due_amount=s.due_amount,
- transfers=[
- {
- "transferAmount": t.transfer_amount,
- "transferType": t.transfer_type or "",
- "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
- if t.transfer_time
- else "",
- "payStatus": t.pay_status or "",
- }
- for t in transfers
- ],
- )
- schedule_status = s.schedule_status or ""
- if schedule_status != "void" and is_schedule_effectively_cleared(
- {"scheduleStatus": schedule_status, "dueAmount": s.due_amount},
- confirmed_paid=Decimal(str(paid or 0)),
- ):
- schedule_status = "cleared"
- return {
- "id": s.id,
- "samplePaymentId": s.sample_payment_id,
- "collectionProtocolId": s.collection_protocol_id,
- "sampleCode": s.sample_code or "",
- "scheduleNo": s.schedule_no,
- "dueAmount": _money_value(s.due_amount, money_as_string),
- "dueDate": s.due_date or "",
- "scheduleStatus": schedule_status,
- "paidAmount": _money_value(paid, money_as_string),
- "arrearsAmount": _money_value(arrears, money_as_string),
- "remark": s.remark or "",
- "transfers": [
- _transfer_to_dict(db, t, tenant_no, money_as_string) for t in transfers
- ],
- }
- def _sample_payment_to_dict(
- db: Session,
- p: BizProtocolSamplePayment,
- schedules: List[BizProtocolPaymentSchedule],
- transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]],
- tenant_no: str,
- money_as_string: bool = False,
- ) -> Dict[str, Any]:
- return {
- "id": p.id,
- "collectionProtocolId": p.collection_protocol_id,
- "protocolSampleId": p.protocol_sample_id or "",
- "sampleCode": p.sample_code or "",
- "payMode": p.pay_mode or "",
- "contractAmount": _money_value(p.contract_amount, money_as_string),
- "installmentCount": p.installment_count,
- "installmentIntervalMonths": float(p.installment_interval_months)
- if p.installment_interval_months is not None
- else None,
- "installmentStartDate": p.installment_start_date or "",
- "installmentAdvanceDays": p.installment_advance_days,
- "installmentRatios": p.installment_ratios or "",
- "lumpSumDeadline": p.lump_sum_deadline or "",
- "remark": p.remark or "",
- "schedules": [
- _schedule_to_dict(
- db, s, transfers_by_schedule.get(s.id, []), tenant_no, money_as_string
- )
- for s in schedules
- ],
- }
- def _normalize_installment_ratios(v: Any) -> Optional[str]:
- if v is None or v == "":
- return None
- if isinstance(v, (list, dict)):
- def normalize_decimal(value: Any) -> Any:
- if isinstance(value, Decimal):
- return format(value.quantize(Decimal("0.01")), "f")
- if isinstance(value, list):
- return [normalize_decimal(item) for item in value]
- if isinstance(value, dict):
- return {
- key: normalize_decimal(item)
- for key, item in value.items()
- }
- return value
- return json.dumps(normalize_decimal(v), ensure_ascii=False)
- return str(v).strip() or None
- def _soft_delete_sample_payment(
- db: Session,
- *,
- sp: BizProtocolSamplePayment,
- tenant_no: str,
- user_id: Optional[str],
- ) -> None:
- now = _now()
- schedules = (
- db.query(BizProtocolPaymentSchedule)
- .filter(
- BizProtocolPaymentSchedule.sample_payment_id == sp.id,
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- .all()
- )
- sids = [s.id for s in schedules]
- if sids:
- transfers = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.schedule_id.in_(sids),
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .all()
- )
- for t in transfers:
- # 方案删除时一并软删待缴费/确认中;已确认笔保留挂接供追溯
- if t.pay_status in ("dfj", "jfqrz"):
- t.del_flag = 1
- t.update_by = user_id
- t.update_time = now
- db.add(t)
- for s in schedules:
- s.del_flag = 1
- s.update_by = user_id
- s.update_time = now
- db.add(s)
- sp.del_flag = 1
- sp.update_by = user_id
- sp.update_time = now
- db.add(sp)
- def save_sample_payments_for_protocol(
- db: Session,
- *,
- collection_protocol_id: str,
- tenant_no: str,
- user_id: Optional[str],
- sample_payments: List[Dict[str, Any]],
- ) -> None:
- """按样本保存缴费方案;锁定批次/转账所在方案禁止结构性变更。"""
- existing = (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- )
- .order_by(BizProtocolSamplePayment.id.asc())
- .populate_existing()
- .with_for_update()
- .all()
- )
- payload_rows = [raw for raw in (sample_payments or []) if isinstance(raw, dict)]
- payload_by_id = {
- str(raw.get("id") or raw.get("samplePaymentId") or "").strip(): raw
- for raw in payload_rows
- if str(raw.get("id") or raw.get("samplePaymentId") or "").strip()
- }
- payload_by_code = {
- str(raw.get("sample_code") or raw.get("sampleCode") or "").strip(): raw
- for raw in payload_rows
- if str(raw.get("sample_code") or raw.get("sampleCode") or "").strip()
- }
- for current in existing:
- _, schedules, transfers = _lock_sample_payment_graph(
- db,
- sample_payment_id=current.id,
- tenant_no=tenant_no,
- )
- if not _locked_schedule_ids(schedules, transfers):
- continue
- raw = payload_by_id.get(current.id) or payload_by_code.get(current.sample_code)
- if raw is None:
- raise ValueError("存在锁定缴费方案,不能省略或删除")
- submitted_mode = str(raw.get("pay_mode") or raw.get("payMode") or "").strip()
- if submitted_mode and submitted_mode != (current.pay_mode or "").strip():
- raise ValueError("存在锁定缴费方案,不能变更缴费模式或重建")
- _assert_locked_schedule_structure(
- schedules,
- transfers,
- list(raw.get("schedules") or []),
- )
- keep_ids: set = set()
- for raw in payload_rows:
- if not isinstance(raw, dict):
- continue
- sample_code = (raw.get("sample_code") or raw.get("sampleCode") or "").strip()
- if not sample_code:
- raise ValueError("样本缴费方案缺少样本编码")
- pay_mode = (raw.get("pay_mode") or raw.get("payMode") or "").strip()
- sp_id = (raw.get("id") or raw.get("samplePaymentId") or "").strip() or None
- ratios = raw.get("installment_ratios")
- if ratios is None:
- ratios = raw.get("installmentRatios")
- prev = find_active_sample_payment(
- db,
- tenant_no=tenant_no,
- sample_payment_id=sp_id,
- collection_protocol_id=collection_protocol_id,
- sample_code=sample_code,
- )
- prev_mode = (prev.pay_mode or "").strip() if prev else ""
- sp = upsert_sample_payment(
- db,
- collection_protocol_id=collection_protocol_id,
- tenant_no=tenant_no,
- user_id=user_id,
- sample_code=sample_code,
- pay_mode=pay_mode,
- contract_amount=raw.get("contract_amount", raw.get("contractAmount")),
- protocol_sample_id=raw.get("protocol_sample_id") or raw.get("protocolSampleId"),
- installment_count=raw.get("installment_count", raw.get("installmentCount")),
- installment_interval_months=raw.get(
- "installment_interval_months", raw.get("installmentIntervalMonths")
- ),
- installment_start_date=raw.get(
- "installment_start_date", raw.get("installmentStartDate")
- ),
- installment_advance_days=raw.get(
- "installment_advance_days", raw.get("installmentAdvanceDays")
- ),
- installment_ratios=_normalize_installment_ratios(ratios),
- lump_sum_deadline=raw.get("lump_sum_deadline", raw.get("lumpSumDeadline")),
- remark=raw.get("remark"),
- sample_payment_id=sp_id,
- )
- keep_ids.add(sp.id)
- if (
- prev_mode == "fq"
- and pay_mode in ("fgd", "dj")
- and sample_payment_has_yjf(
- db, sample_payment_id=sp.id, tenant_no=tenant_no
- )
- ):
- apply_fq_to_fgd_merge(
- db,
- sample_payment_id=sp.id,
- tenant_no=tenant_no,
- user_id=user_id,
- target_pay_mode=pay_mode,
- )
- continue
- schedules_payload = raw.get("schedules") or []
- require_fields = pay_mode in ("fq", "fgd", "djfq")
- ok, mismatch_msg = schedule_dues_match_contract(
- contract_amount=raw.get("contract_amount", raw.get("contractAmount")),
- schedules=schedules_payload,
- sample_code=sample_code,
- require_schedule_fields=require_fields,
- )
- if not ok:
- raise ValueError(mismatch_msg or "各期应缴合计与合同金额不一致")
- sync_schedules_and_transfers(
- db,
- sample_payment_id=sp.id,
- tenant_no=tenant_no,
- user_id=user_id,
- schedules=schedules_payload,
- )
- for sp in existing:
- if sp.id not in keep_ids:
- _soft_delete_sample_payment(
- db, sp=sp, tenant_no=tenant_no, user_id=user_id
- )
- db.commit()
- def list_deposit_transfers_for_protocol(
- db: Session,
- *,
- source_presign_order_id: Optional[str],
- tenant_no: str,
- money_as_string: bool = False,
- ) -> List[Dict[str, Any]]:
- """正式协议意向金列表:始终读预签订单 biz_presign_transfer,保证财务审批两边一致。"""
- pid = (source_presign_order_id or "").strip()
- if not pid:
- return []
- from app.crud.presign_order import list_transfers
- result = list_transfers(db, presign_order_id=pid, tenant_no=tenant_no)
- if money_as_string and result:
- rows = (
- db.query(BizPresignTransfer)
- .filter(
- BizPresignTransfer.id.in_([item.get('id') for item in result]),
- BizPresignTransfer.tenant_no == tenant_no,
- BizPresignTransfer.del_flag == 0,
- )
- .all()
- )
- amount_by_id = {str(row.id): _money_value(row.transfer_amount, True) for row in rows}
- for item in result:
- item['transferAmount'] = amount_by_id.get(str(item.get('id') or ''))
- return result
- def get_presign_deposit_hint(
- db: Session,
- *,
- source_presign_order_id: Optional[str],
- tenant_no: str,
- money_as_string: bool = False,
- ) -> Optional[Dict[str, Any]]:
- pid = (source_presign_order_id or "").strip()
- if not pid:
- return None
- rows = (
- db.query(BizPresignTransfer)
- .filter(
- BizPresignTransfer.presign_order_id == pid,
- BizPresignTransfer.tenant_no == tenant_no,
- BizPresignTransfer.del_flag == 0,
- BizPresignTransfer.pay_status == "yjf",
- )
- .all()
- )
- total = Decimal("0")
- for r in rows:
- if r.transfer_amount is not None:
- total += Decimal(str(r.transfer_amount))
- return {
- "presignOrderId": pid,
- "confirmedDepositAmount": _money_value(total, money_as_string),
- "confirmedTransferCount": len(rows),
- }
- def get_sample_payments_bundle(
- db: Session,
- collection_protocol_id: str,
- tenant_no: str,
- money_as_string: bool = False,
- ) -> List[Dict[str, Any]]:
- payments = (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- )
- .order_by(
- BizProtocolSamplePayment.order_num.asc(),
- BizProtocolSamplePayment.create_time.asc(),
- )
- .all()
- )
- if not payments:
- return []
- pids = [p.id for p in payments]
- schedules = (
- db.query(BizProtocolPaymentSchedule)
- .filter(
- BizProtocolPaymentSchedule.sample_payment_id.in_(pids),
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- .order_by(
- BizProtocolPaymentSchedule.order_num.asc(),
- BizProtocolPaymentSchedule.schedule_no.asc(),
- )
- .all()
- )
- sids = [s.id for s in schedules]
- transfers: List[BizProtocolPaymentTransfer] = []
- if sids:
- transfers = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.schedule_id.in_(sids),
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .order_by(BizProtocolPaymentTransfer.create_time.asc())
- .all()
- )
- by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
- for t in transfers:
- by_schedule.setdefault(t.schedule_id, []).append(t)
- schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
- for s in schedules:
- schedules_by_payment.setdefault(s.sample_payment_id, []).append(s)
- return [
- _sample_payment_to_dict(
- db,
- p,
- schedules_by_payment.get(p.id, []),
- by_schedule,
- tenant_no,
- money_as_string,
- )
- for p in payments
- ]
- def list_transfers_for_finance(
- db: Session,
- *,
- pay_status: str,
- tenant_no: str,
- keyword: Optional[str] = None,
- page: int = 1,
- page_size: int = 10,
- user_info=None,
- due_date_start: Optional[str] = None,
- due_date_end: Optional[str] = None,
- ) -> Tuple[List[Dict[str, Any]], int]:
- if not tenant_no:
- return [], 0
- st = (pay_status or "").strip()
- q = (
- db.query(
- BizProtocolPaymentTransfer,
- BizCollectionProtocol,
- BizProtocolPartyCustomer,
- BizProtocolPaymentSchedule,
- BizProtocolSamplePayment,
- )
- .join(
- BizCollectionProtocol,
- and_(
- BizCollectionProtocol.id
- == BizProtocolPaymentTransfer.collection_protocol_id,
- BizCollectionProtocol.del_flag == 0,
- ),
- )
- .outerjoin(
- BizProtocolPartyCustomer,
- and_(
- BizProtocolPartyCustomer.collection_protocol_id
- == BizProtocolPaymentTransfer.collection_protocol_id,
- BizProtocolPartyCustomer.tenant_no == tenant_no,
- BizProtocolPartyCustomer.del_flag == 0,
- ),
- )
- .outerjoin(
- BizProtocolPaymentSchedule,
- and_(
- BizProtocolPaymentSchedule.id == BizProtocolPaymentTransfer.schedule_id,
- BizProtocolPaymentSchedule.del_flag == 0,
- ),
- )
- .outerjoin(
- BizProtocolSamplePayment,
- and_(
- BizProtocolSamplePayment.id
- == BizProtocolPaymentTransfer.sample_payment_id,
- BizProtocolSamplePayment.del_flag == 0,
- ),
- )
- .filter(
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- BizProtocolPaymentTransfer.pay_status == st,
- )
- )
- kw = (keyword or "").strip()
- if kw:
- like = f"%{kw}%"
- q = q.filter(
- or_(
- BizCollectionProtocol.protocol_no.like(like),
- BizProtocolPaymentTransfer.sample_code.like(like),
- BizProtocolPartyCustomer.mother_name.like(like),
- BizProtocolPartyCustomer.mother_id_card.like(like),
- BizProtocolPartyCustomer.newborn_name.like(like),
- BizProtocolPartyCustomer.newborn_id_card.like(like),
- )
- )
- ds = (due_date_start or "").strip().replace("-", "")[:8]
- de = (due_date_end or "").strip().replace("-", "")[:8]
- if ds:
- q = q.filter(BizProtocolPaymentSchedule.due_date >= ds)
- if de:
- q = q.filter(BizProtocolPaymentSchedule.due_date <= de)
- if user_info:
- from app.core.data_scope import apply_data_scope
- q = apply_data_scope(
- q, BizProtocolPaymentTransfer, user_info, db, tenant_no
- )
- total = q.order_by(None).with_entities(func.count(BizProtocolPaymentTransfer.id)).scalar() or 0
- page = max(1, page)
- page_size = min(max(1, page_size), 100)
- rows = (
- q.order_by(BizProtocolPaymentTransfer.create_time.desc())
- .offset((page - 1) * page_size)
- .limit(page_size)
- .all()
- )
- out: List[Dict[str, Any]] = []
- for t, proto, party, sch, sp in rows:
- item = _transfer_to_dict(db, t, tenant_no)
- item["protocolNo"] = (proto.protocol_no if proto else "") or ""
- item["protocolStatus"] = (proto.protocol_status if proto else "") or ""
- item["voidType"] = (proto.void_type if proto else "") or ""
- item["motherName"] = (party.mother_name if party else "") or ""
- item["motherIdCard"] = (party.mother_id_card if party else "") or ""
- item["newbornName"] = (party.newborn_name if party else "") or ""
- item["newbornIdCard"] = (party.newborn_id_card if party else "") or ""
- item["installmentNo"] = sch.schedule_no if sch else None
- item["installmentDueDate"] = (sch.due_date if sch else "") or ""
- item["dueAmount"] = (
- float(sch.due_amount) if sch and sch.due_amount is not None else None
- )
- item["payMode"] = (sp.pay_mode if sp else "") or ""
- out.append(item)
- return out, int(total)
- def _filter_and_page_progress(
- rows: List[Dict[str, Any]],
- progress_status: Optional[str],
- page: int,
- page_size: int,
- ) -> Tuple[List[Dict[str, Any]], int]:
- """先按 progressStatus 过滤,再分页切片(禁止先 LIMIT 再筛进度)。"""
- filtered = rows
- ps = (progress_status or "").strip()
- if ps:
- filtered = [r for r in rows if (r.get("progressStatus") or "") == ps]
- total = len(filtered)
- page = max(1, page)
- page_size = min(max(1, page_size), 100)
- start = (page - 1) * page_size
- return filtered[start : start + page_size], total
- def _progress_to_json_fields(progress: Dict[str, Any]) -> Dict[str, Any]:
- paid = progress.get("paidAmount")
- due_remain = progress.get("dueRemainAmount")
- return {
- "progressStatus": progress.get("progressStatus") or "",
- "paidAmount": float(paid) if paid is not None else 0.0,
- "dueRemainAmount": float(due_remain) if due_remain is not None else 0.0,
- "clearedScheduleCount": progress.get("clearedScheduleCount") or 0,
- "activeScheduleCount": progress.get("activeScheduleCount") or 0,
- "canConfirm": bool(progress.get("canConfirm")),
- }
- def _load_payment_children_batch(
- db: Session,
- sample_payment_ids: List[str],
- tenant_no: str,
- ) -> Tuple[
- Dict[str, List[BizProtocolPaymentSchedule]],
- Dict[str, List[BizProtocolPaymentTransfer]],
- ]:
- schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
- transfers_by_payment: Dict[str, List[BizProtocolPaymentTransfer]] = {}
- if not sample_payment_ids:
- return schedules_by_payment, transfers_by_payment
- schedules = (
- db.query(BizProtocolPaymentSchedule)
- .filter(
- BizProtocolPaymentSchedule.sample_payment_id.in_(sample_payment_ids),
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- )
- .order_by(
- BizProtocolPaymentSchedule.order_num.asc(),
- BizProtocolPaymentSchedule.schedule_no.asc(),
- )
- .all()
- )
- sids = [s.id for s in schedules]
- transfers: List[BizProtocolPaymentTransfer] = []
- if sids:
- transfers = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.schedule_id.in_(sids),
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .order_by(BizProtocolPaymentTransfer.create_time.asc())
- .all()
- )
- for s in schedules:
- schedules_by_payment.setdefault(s.sample_payment_id, []).append(s)
- schedule_to_payment = {s.id: s.sample_payment_id for s in schedules}
- for t in transfers:
- pid = schedule_to_payment.get(t.schedule_id) or t.sample_payment_id
- if pid:
- transfers_by_payment.setdefault(pid, []).append(t)
- return schedules_by_payment, transfers_by_payment
- def _compute_progress_for_payment(
- schedules: List[BizProtocolPaymentSchedule],
- transfers: List[BizProtocolPaymentTransfer],
- ) -> Dict[str, Any]:
- sch_dicts = [
- {
- "id": s.id,
- "scheduleStatus": s.schedule_status or "",
- "dueAmount": s.due_amount,
- }
- for s in schedules
- ]
- tr_dicts = [
- {
- "scheduleId": t.schedule_id,
- "payStatus": t.pay_status or "",
- "transferAmount": t.transfer_amount,
- }
- for t in transfers
- ]
- return compute_sample_payment_progress(schedules=sch_dicts, transfers=tr_dicts)
- def _build_list_summary_row(
- sp: BizProtocolSamplePayment,
- proto: BizCollectionProtocol,
- party: Optional[BizProtocolPartyCustomer],
- progress: Dict[str, Any],
- *,
- schedules: Optional[List[BizProtocolPaymentSchedule]] = None,
- transfers: Optional[List[BizProtocolPaymentTransfer]] = None,
- sample_meta: Optional[Dict[str, Any]] = None,
- ) -> Dict[str, Any]:
- sch_list = schedules or []
- tr_list = transfers or []
- meta = sample_meta or {}
- active_dues = [
- s.due_amount
- for s in sch_list
- if (s.schedule_status or "") != "void" and s.due_amount is not None
- ]
- receivable = float(sum(active_dues, Decimal("0"))) if active_dues else 0.0
- discount = meta.get("discountAmount")
- row: Dict[str, Any] = {
- "id": sp.id,
- "collectionProtocolId": sp.collection_protocol_id,
- "protocolNo": (proto.protocol_no if proto else "") or "",
- "protocolStatus": (proto.protocol_status if proto else "") or "",
- "voidType": (proto.void_type if proto else "") or "",
- "motherName": (party.mother_name if party else "") or "",
- "motherPhone": (party.mother_phone if party else "") or "",
- "sampleCode": sp.sample_code or "",
- "payMode": sp.pay_mode or "",
- "contractAmount": float(sp.contract_amount)
- if sp.contract_amount is not None
- else None,
- "signDate": _fmt_ymd8(getattr(proto, "register_date", None) or ""),
- "storageYears": meta.get("storageYears"),
- "sampleTypeName": meta.get("sampleTypeName") or "",
- "discountAmount": discount,
- "receivableAmount": receivable,
- "remark": sp.remark or "",
- "periods": _build_periods_array(sch_list, tr_list),
- }
- row.update(_progress_to_json_fields(progress))
- return row
- def list_sample_payments_page(
- db: Session,
- *,
- tenant_no: str,
- keyword: Optional[str] = None,
- pay_mode: Optional[str] = None,
- progress_status: Optional[str] = None,
- due_date_start: Optional[str] = None,
- due_date_end: Optional[str] = None,
- page: int = 1,
- page_size: int = 10,
- user_info=None,
- ) -> Tuple[List[Dict[str, Any]], int]:
- if not tenant_no:
- return [], 0
- q = (
- db.query(
- BizProtocolSamplePayment,
- BizCollectionProtocol,
- BizProtocolPartyCustomer,
- )
- .join(
- BizCollectionProtocol,
- and_(
- BizCollectionProtocol.id
- == BizProtocolSamplePayment.collection_protocol_id,
- BizCollectionProtocol.del_flag == 0,
- ),
- )
- .outerjoin(
- BizProtocolPartyCustomer,
- and_(
- BizProtocolPartyCustomer.collection_protocol_id
- == BizProtocolSamplePayment.collection_protocol_id,
- BizProtocolPartyCustomer.tenant_no == tenant_no,
- BizProtocolPartyCustomer.del_flag == 0,
- ),
- )
- .filter(
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- )
- )
- kw = (keyword or "").strip()
- if kw:
- like = f"%{kw}%"
- q = q.filter(
- or_(
- BizCollectionProtocol.protocol_no.like(like),
- BizProtocolSamplePayment.sample_code.like(like),
- BizProtocolPartyCustomer.mother_name.like(like),
- BizProtocolPartyCustomer.mother_phone.like(like),
- )
- )
- mode = (pay_mode or "").strip()
- if mode:
- q = q.filter(BizProtocolSamplePayment.pay_mode == mode)
- ds = (due_date_start or "").strip().replace("-", "")[:8]
- de = (due_date_end or "").strip().replace("-", "")[:8]
- if ds or de:
- due_conditions = [
- BizProtocolPaymentSchedule.sample_payment_id
- == BizProtocolSamplePayment.id,
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- BizProtocolPaymentSchedule.schedule_status != "void",
- ]
- if ds:
- due_conditions.append(BizProtocolPaymentSchedule.due_date >= ds)
- if de:
- due_conditions.append(BizProtocolPaymentSchedule.due_date <= de)
- q = q.filter(exists().where(and_(*due_conditions)))
- if user_info:
- from app.core.data_scope import apply_data_scope
- q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
- candidates = q.order_by(BizProtocolSamplePayment.create_time.desc()).all()
- if not candidates:
- return [], 0
- pids = [sp.id for sp, _, _ in candidates]
- schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
- db, pids, tenant_no
- )
- sample_codes = list({(sp.sample_code or "").strip() for sp, _, _ in candidates if sp.sample_code})
- protocol_ids = list({sp.collection_protocol_id for sp, _, _ in candidates})
- sample_meta_by_key: Dict[str, Dict[str, Any]] = {}
- if sample_codes and protocol_ids:
- s_rows = (
- db.query(BizProtocolSample)
- .filter(
- BizProtocolSample.collection_protocol_id.in_(protocol_ids),
- BizProtocolSample.tenant_no == tenant_no,
- BizProtocolSample.del_flag == 0,
- BizProtocolSample.sample_code.in_(sample_codes),
- )
- .all()
- )
- type_ids = list({s.sample_type_id for s in s_rows if s.sample_type_id})
- type_name_map: Dict[str, str] = {}
- if type_ids:
- for t in (
- db.query(BizCellSampleType)
- .filter(BizCellSampleType.id.in_(type_ids), BizCellSampleType.del_flag == 0)
- .all()
- ):
- type_name_map[t.id] = t.type_name or ""
- for s in s_rows:
- key = f"{s.collection_protocol_id}|{(s.sample_code or '').strip()}"
- orig = float(s.original_price) if s.original_price is not None else None
- price = float(s.price) if s.price is not None else None
- discount = None
- if orig is not None and price is not None and orig > price:
- discount = round(orig - price, 2)
- sample_meta_by_key[key] = {
- "storageYears": s.storage_years,
- "sampleTypeName": type_name_map.get(s.sample_type_id or "", ""),
- "discountAmount": discount,
- }
- rows: List[Dict[str, Any]] = []
- for sp, proto, party in candidates:
- schedules = schedules_by_payment.get(sp.id, [])
- transfers = transfers_by_payment.get(sp.id, [])
- progress = _compute_progress_for_payment(schedules, transfers)
- meta_key = f"{sp.collection_protocol_id}|{(sp.sample_code or '').strip()}"
- rows.append(
- _build_list_summary_row(
- sp,
- proto,
- party,
- progress,
- schedules=schedules,
- transfers=transfers,
- sample_meta=sample_meta_by_key.get(meta_key),
- )
- )
- return _filter_and_page_progress(rows, progress_status, page, page_size)
- def get_sample_payment_detail(
- db: Session,
- *,
- sample_payment_id: str,
- tenant_no: str,
- user_info=None,
- ) -> Optional[Dict[str, Any]]:
- q = (
- db.query(
- BizProtocolSamplePayment,
- BizCollectionProtocol,
- BizProtocolPartyCustomer,
- )
- .join(
- BizCollectionProtocol,
- and_(
- BizCollectionProtocol.id
- == BizProtocolSamplePayment.collection_protocol_id,
- BizCollectionProtocol.del_flag == 0,
- ),
- )
- .outerjoin(
- BizProtocolPartyCustomer,
- and_(
- BizProtocolPartyCustomer.collection_protocol_id
- == BizProtocolSamplePayment.collection_protocol_id,
- BizProtocolPartyCustomer.tenant_no == tenant_no,
- BizProtocolPartyCustomer.del_flag == 0,
- ),
- )
- .filter(
- BizProtocolSamplePayment.id == sample_payment_id,
- BizProtocolSamplePayment.tenant_no == tenant_no,
- BizProtocolSamplePayment.del_flag == 0,
- )
- )
- if user_info:
- from app.core.data_scope import apply_data_scope
- q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
- row = q.first()
- if not row:
- return None
- sp, proto, party = row
- schedules_by_payment, transfers_by_payment = _load_payment_children_batch(
- db, [sp.id], tenant_no
- )
- schedules = schedules_by_payment.get(sp.id, [])
- transfers = transfers_by_payment.get(sp.id, [])
- by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
- for t in transfers:
- by_schedule.setdefault(t.schedule_id, []).append(t)
- detail = _sample_payment_to_dict(db, sp, schedules, by_schedule, tenant_no)
- detail["protocolNo"] = (proto.protocol_no if proto else "") or ""
- detail["protocolStatus"] = (proto.protocol_status if proto else "") or ""
- detail["voidType"] = (proto.void_type if proto else "") or ""
- detail["motherName"] = (party.mother_name if party else "") or ""
- detail["motherPhone"] = (party.mother_phone if party else "") or ""
- progress = _compute_progress_for_payment(schedules, transfers)
- detail.update(_progress_to_json_fields(progress))
- return detail
- def list_payment_chase_page(
- db: Session,
- *,
- tenant_no: str,
- keyword: Optional[str] = None,
- chase_type: Optional[str] = None,
- pay_mode: Optional[str] = None,
- sales_user_id: Optional[str] = None,
- page: int = 1,
- page_size: int = 10,
- user_info=None,
- today: Optional[Any] = None,
- ) -> Tuple[List[Dict[str, Any]], int]:
- """催缴列表:一行 = 一个欠缴>0 且(逾期或未来30天内)的应缴期。"""
- from datetime import date as date_cls
- if not tenant_no:
- return [], 0
- today_d = today if isinstance(today, date_cls) else date_cls.today()
- q = (
- db.query(
- BizProtocolPaymentSchedule,
- BizProtocolSamplePayment,
- BizCollectionProtocol,
- BizProtocolPartyCustomer,
- BizProtocolCollectionInfo,
- )
- .join(
- BizProtocolSamplePayment,
- and_(
- BizProtocolSamplePayment.id
- == BizProtocolPaymentSchedule.sample_payment_id,
- BizProtocolSamplePayment.del_flag == 0,
- ),
- )
- .join(
- BizCollectionProtocol,
- and_(
- BizCollectionProtocol.id
- == BizProtocolPaymentSchedule.collection_protocol_id,
- BizCollectionProtocol.del_flag == 0,
- ),
- )
- .outerjoin(
- BizProtocolPartyCustomer,
- and_(
- BizProtocolPartyCustomer.collection_protocol_id
- == BizProtocolPaymentSchedule.collection_protocol_id,
- BizProtocolPartyCustomer.tenant_no == tenant_no,
- BizProtocolPartyCustomer.del_flag == 0,
- ),
- )
- .outerjoin(
- BizProtocolCollectionInfo,
- and_(
- BizProtocolCollectionInfo.collection_protocol_id
- == BizProtocolPaymentSchedule.collection_protocol_id,
- BizProtocolCollectionInfo.tenant_no == tenant_no,
- BizProtocolCollectionInfo.del_flag == 0,
- ),
- )
- .filter(
- BizProtocolPaymentSchedule.tenant_no == tenant_no,
- BizProtocolPaymentSchedule.del_flag == 0,
- BizProtocolPaymentSchedule.schedule_status != "void",
- )
- )
- kw = (keyword or "").strip()
- if kw:
- like = f"%{kw}%"
- q = q.filter(
- or_(
- BizCollectionProtocol.protocol_no.like(like),
- BizProtocolSamplePayment.sample_code.like(like),
- BizProtocolPartyCustomer.mother_name.like(like),
- BizProtocolPartyCustomer.mother_phone.like(like),
- )
- )
- mode = (pay_mode or "").strip()
- if mode:
- q = q.filter(BizProtocolSamplePayment.pay_mode == mode)
- sid = (sales_user_id or "").strip()
- if sid:
- q = q.filter(BizProtocolCollectionInfo.sales_user_id == sid)
- if user_info:
- from app.core.data_scope import apply_data_scope
- q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
- candidates = q.all()
- if not candidates:
- return [], 0
- sch_ids = [sch.id for sch, _, _, _, _ in candidates]
- transfers = (
- db.query(BizProtocolPaymentTransfer)
- .filter(
- BizProtocolPaymentTransfer.schedule_id.in_(sch_ids),
- BizProtocolPaymentTransfer.tenant_no == tenant_no,
- BizProtocolPaymentTransfer.del_flag == 0,
- )
- .all()
- )
- by_sch: Dict[str, List[BizProtocolPaymentTransfer]] = {}
- for t in transfers:
- by_sch.setdefault(t.schedule_id, []).append(t)
- sales_ids = list(
- {
- (ci.sales_user_id or "").strip()
- for _, _, _, _, ci in candidates
- if ci and ci.sales_user_id
- }
- )
- sales_name_map: Dict[str, str] = {}
- if sales_ids:
- for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(sales_ids)).all():
- sales_name_map[u.id] = u.nick_name or ""
- ct = (chase_type or "").strip()
- rows: List[Dict[str, Any]] = []
- for sch, sp, proto, party, ci in candidates:
- due_d = parse_due_date_ymd(sch.due_date)
- if due_d is None:
- continue
- tr_maps = [
- {
- "transferAmount": t.transfer_amount,
- "transferType": t.transfer_type or "",
- "transferTime": t.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
- if t.transfer_time
- else "",
- "payStatus": t.pay_status or "",
- }
- for t in by_sch.get(sch.id, [])
- ]
- paid, arr = chase_period_money(
- due_amount=sch.due_amount, transfers=tr_maps
- )
- if not is_chase_candidate(due_date=due_d, today=today_d, arrears=arr):
- continue
- overdue = due_d < today_d
- if ct == "overdue" and not overdue:
- continue
- if ct == "upcoming" and overdue:
- continue
- days_overdue = (today_d - due_d).days if overdue else 0
- days_until = (due_d - today_d).days if not overdue else 0
- rows.append(
- {
- "scheduleId": sch.id,
- "samplePaymentId": sp.id,
- "protocolId": proto.id if proto else sch.collection_protocol_id,
- "protocolNo": (proto.protocol_no if proto else "") or "",
- "motherName": (party.mother_name if party else "") or "",
- "motherPhone": (party.mother_phone if party else "") or "",
- "sampleCode": sp.sample_code or sch.sample_code or "",
- "salesUserId": (ci.sales_user_id if ci else "") or "",
- "salesUserName": sales_name_map.get(
- (ci.sales_user_id if ci else "") or "", ""
- ),
- "payMode": sp.pay_mode or "",
- "scheduleNo": sch.schedule_no,
- "dueDate": _fmt_ymd8(sch.due_date),
- "dueAmount": float(sch.due_amount) if sch.due_amount is not None else None,
- "paidAmount": float(paid),
- "arrearsAmount": float(arr),
- "chaseStatus": "overdue" if overdue else "upcoming",
- "daysOverdue": days_overdue,
- "daysUntilDue": days_until,
- "protocolStatus": (proto.protocol_status if proto else "") or "",
- "voidType": (proto.void_type if proto else "") or "",
- }
- )
- rows.sort(
- key=lambda r: (
- 0 if r["chaseStatus"] == "overdue" else 1,
- -int(r.get("daysOverdue") or 0),
- str(r.get("dueDate") or ""),
- str(r.get("protocolNo") or ""),
- )
- )
- total = len(rows)
- page = max(1, page)
- page_size = min(max(1, page_size), 100)
- start = (page - 1) * page_size
- return rows[start : start + page_size], total
|