1927 lines
66 KiB
Python
1927 lines
66 KiB
Python
"""按样本缴费方案 / 应缴批次 / 转账笔 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
|
||
|
||
|
||
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 _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 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:
|
||
return (
|
||
db.query(BizProtocolSamplePayment)
|
||
.filter(
|
||
BizProtocolSamplePayment.id == sample_payment_id,
|
||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||
BizProtocolSamplePayment.del_flag == 0,
|
||
)
|
||
.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 = _get_sample_payment_or_raise(
|
||
db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
|
||
)
|
||
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,
|
||
)
|
||
.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 not can_edit_transfer(t.pay_status):
|
||
# 已确认收款:保留挂接,不改内容
|
||
continue
|
||
_apply_transfer_editable_fields(t, raw)
|
||
t.update_by = user_id
|
||
t.update_time = now
|
||
db.add(t)
|
||
continue
|
||
# 新增转账笔(始终以待缴费入库;提交财务走独立接口)
|
||
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 == "yjf":
|
||
# 已确认笔不随表单漏传而删除
|
||
continue
|
||
if not can_edit_transfer(t.pay_status):
|
||
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]:
|
||
"""软删原批次(及其下待缴费转账),重建批次;确认中/已确认笔按期号挂回新批次。"""
|
||
sp = _get_sample_payment_or_raise(
|
||
db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
|
||
)
|
||
now = _now()
|
||
old_schedules = (
|
||
db.query(BizProtocolPaymentSchedule)
|
||
.filter(
|
||
BizProtocolPaymentSchedule.sample_payment_id == sample_payment_id,
|
||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||
BizProtocolPaymentSchedule.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
old_no_by_id = {s.id: int(s.schedule_no or 0) for s in old_schedules}
|
||
old_ids = list(old_no_by_id.keys())
|
||
preserve: List[Tuple[BizProtocolPaymentTransfer, int]] = []
|
||
if old_ids:
|
||
transfers = (
|
||
db.query(BizProtocolPaymentTransfer)
|
||
.filter(
|
||
BizProtocolPaymentTransfer.schedule_id.in_(old_ids),
|
||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||
BizProtocolPaymentTransfer.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
for t in transfers:
|
||
if t.pay_status in ("jfqrz", "yjf"):
|
||
preserve.append((t, old_no_by_id.get(t.schedule_id, 0)))
|
||
elif t.pay_status == "dfj":
|
||
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_status = (
|
||
raw.get("schedule_status") or raw.get("scheduleStatus") or "open"
|
||
)
|
||
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_of(raw, idx + 1),
|
||
due_amount=_dec(raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")),
|
||
due_date=due_date,
|
||
schedule_status=(str(schedule_status).strip() or "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()
|
||
|
||
created_by_no = {int(s.schedule_no or 0): s for s in created}
|
||
fallback = created[0] if created else None
|
||
for t, old_no in preserve:
|
||
target = created_by_no.get(old_no) or fallback
|
||
if not target:
|
||
continue
|
||
t.schedule_id = target.id
|
||
t.sample_payment_id = sp.id
|
||
t.update_by = user_id
|
||
t.update_time = now
|
||
db.add(t)
|
||
if preserve:
|
||
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]:
|
||
"""
|
||
同步应缴批次与转账笔:
|
||
- 批次期号集合不变:就地更新批次字段,并按笔同步转账(dfj/jfqrz 可增改删;yjf 只读保留)
|
||
- 批次结构变化:重建批次,确认中/已确认笔按期号挂回
|
||
"""
|
||
_get_sample_payment_or_raise(
|
||
db, sample_payment_id=sample_payment_id, tenant_no=tenant_no
|
||
)
|
||
now = _now()
|
||
old_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()
|
||
)
|
||
by_no = {int(s.schedule_no or 0): s for s in old_schedules}
|
||
payload = list(schedules or [])
|
||
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
|
||
schedule_status = (
|
||
raw.get("schedule_status") or raw.get("scheduleStatus") or sch.schedule_status
|
||
or "open"
|
||
)
|
||
sch.due_amount = _dec(
|
||
raw.get("due_amount") if "due_amount" in raw else raw.get("dueAmount")
|
||
)
|
||
sch.due_date = due_date
|
||
sch.schedule_status = (str(schedule_status).strip() or "open")
|
||
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],
|
||
) -> BizProtocolPaymentTransfer:
|
||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||
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,
|
||
) -> BizProtocolPaymentTransfer:
|
||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||
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,
|
||
) -> BizProtocolPaymentTransfer:
|
||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||
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)
|
||
db.commit()
|
||
db.refresh(t)
|
||
return t
|
||
|
||
|
||
def reject_receipt(
|
||
db: Session,
|
||
*,
|
||
transfer_id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str],
|
||
) -> BizProtocolPaymentTransfer:
|
||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||
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],
|
||
) -> BizProtocolPaymentTransfer:
|
||
"""业务撤回:jfqrz → dfj,保留 finance_remark / finance_confirm_attachments。"""
|
||
t = _get_transfer_or_raise(db, transfer_id=transfer_id, tenant_no=tenant_no)
|
||
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 _transfer_to_dict(
|
||
db: Session, t: BizProtocolPaymentTransfer, tenant_no: str
|
||
) -> 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": float(t.transfer_amount) if t.transfer_amount is not None else None,
|
||
"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,
|
||
) -> Dict[str, Any]:
|
||
# 展示口径与缴费信息期次一致:已缴 = dfj + jfqrz + yjf
|
||
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
|
||
],
|
||
)
|
||
return {
|
||
"id": s.id,
|
||
"samplePaymentId": s.sample_payment_id,
|
||
"collectionProtocolId": s.collection_protocol_id,
|
||
"sampleCode": s.sample_code or "",
|
||
"scheduleNo": s.schedule_no,
|
||
"dueAmount": float(s.due_amount) if s.due_amount is not None else None,
|
||
"dueDate": s.due_date or "",
|
||
"scheduleStatus": s.schedule_status or "",
|
||
"paidAmount": float(paid),
|
||
"arrearsAmount": float(arrears),
|
||
"remark": s.remark or "",
|
||
"transfers": [_transfer_to_dict(db, t, tenant_no) 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,
|
||
) -> 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": float(p.contract_amount) if p.contract_amount is not None else None,
|
||
"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
|
||
)
|
||
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)):
|
||
return json.dumps(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:
|
||
"""按样本保存缴费方案:upsert 方案 → 同步批次与转账笔(确认中可改,已确认只读)。"""
|
||
existing = (
|
||
db.query(BizProtocolSamplePayment)
|
||
.filter(
|
||
BizProtocolSamplePayment.collection_protocol_id == collection_protocol_id,
|
||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||
BizProtocolSamplePayment.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
keep_ids: set = set()
|
||
for raw in sample_payments or []:
|
||
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
|
||
) -> 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
|
||
|
||
return list_transfers(db, presign_order_id=pid, tenant_no=tenant_no)
|
||
|
||
|
||
def get_presign_deposit_hint(
|
||
db: Session, *, source_presign_order_id: Optional[str], tenant_no: str
|
||
) -> 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": float(total),
|
||
"confirmedTransferCount": len(rows),
|
||
}
|
||
|
||
|
||
def get_sample_payments_bundle(
|
||
db: Session, collection_protocol_id: str, tenant_no: str
|
||
) -> 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,
|
||
)
|
||
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 = [
|
||
{
|
||
"scheduleStatus": s.schedule_status or "",
|
||
"dueAmount": s.due_amount,
|
||
}
|
||
for s in schedules
|
||
]
|
||
tr_dicts = [
|
||
{
|
||
"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
|