Files
zkxw/backend/app/services/market_payment.py
T
2026-10-14 12:32:03 +08:00

1285 lines
44 KiB
Python

"""市场专员/地区经理移动端的催缴与按样本缴费服务。"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import Integer, and_, case, cast, exists, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.core.utils import parse_datetime
from app.crud import protocol_sample_payment as payment_crud
from app.models.models import (
BizCellSampleType,
BizCollectionProtocol,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolSample,
BizProtocolSamplePayment,
SysUser,
)
from app.services.market_field_scope import (
MarketFieldContext,
apply_cell_protocol_scope,
)
from app.services.payment_chase import (
chase_period_money,
is_chase_candidate,
parse_due_date_ymd,
)
from app.services.sample_payment_progress import compute_sample_payment_progress
from app.services.market_support import (
validate_attachment_refs,
)
def _current_dept_id(user_info: dict) -> str:
return str(
user_info.get('dept_id')
or user_info.get('deptId')
or user_info.get('region_id')
or ''
).strip()
def _fmt_time(value: Any) -> str:
return value.strftime('%Y-%m-%d %H:%M:%S') if value else ''
def _money(value: Any) -> Optional[str]:
"""以固定两位小数字符串输出,避免 JSON float 破坏 DECIMAL(18,2)。"""
if value is None or value == '':
return None
return format(Decimal(str(value)).quantize(Decimal('0.01')), 'f')
def _display_ymd(value: Any) -> str:
text = str(value or '').strip()
if len(text) == 8 and text.isdigit():
return f'{text[:4]}-{text[4:6]}-{text[6:8]}'
return text
def _load_sample_meta(
db: Session,
payments: List[BizProtocolSamplePayment],
tenant_no: str,
) -> Dict[str, Dict[str, Any]]:
sample_codes = list({str(row.sample_code or '').strip() for row in payments if row.sample_code})
if not sample_codes:
return {}
rows = (
db.query(BizProtocolSample, BizCellSampleType)
.outerjoin(
BizCellSampleType,
and_(
BizCellSampleType.id == BizProtocolSample.sample_type_id,
BizCellSampleType.tenant_no == tenant_no,
BizCellSampleType.del_flag == 0,
),
)
.filter(
BizProtocolSample.sample_code.in_(sample_codes),
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
)
.order_by(
BizProtocolSample.update_time.desc(),
BizProtocolSample.create_time.desc(),
BizProtocolSample.id.desc(),
)
.all()
)
result: Dict[str, Dict[str, Any]] = {}
for sample, sample_type in rows:
original = sample.original_price
current = sample.price
discount = None
if original is not None and current is not None and original > current:
discount = original - current
result.setdefault(
sample.sample_code or '',
{
'storageYears': sample.storage_years,
'sampleTypeName': sample_type.type_name if sample_type else '',
'discountAmount': _money(discount),
},
)
return result
def _transfer_dict(row: BizProtocolPaymentTransfer) -> Dict[str, Any]:
return {
'id': row.id,
'scheduleId': row.schedule_id,
'samplePaymentId': row.sample_payment_id,
'collectionProtocolId': row.collection_protocol_id,
'sampleCode': row.sample_code or '',
'transferAmount': _money(row.transfer_amount),
'transferTime': _fmt_time(row.transfer_time),
'transferType': row.transfer_type or '',
'transferAttachments': row.transfer_attachments or '',
'payStatus': row.pay_status or '',
'remark': row.remark or '',
'financeRemark': row.finance_remark or '',
'createTime': _fmt_time(row.create_time),
'updateTime': _fmt_time(row.update_time),
}
def _period_dict(
row: BizProtocolPaymentSchedule,
transfers: List[BizProtocolPaymentTransfer],
) -> Dict[str, Any]:
paid, arrears = chase_period_money(
due_amount=row.due_amount,
transfers=[
{'transferAmount': item.transfer_amount, 'payStatus': item.pay_status}
for item in transfers
],
)
return {
'id': row.id,
'scheduleNo': row.schedule_no,
'dueAmount': _money(row.due_amount),
'dueDate': row.due_date or '',
'scheduleStatus': row.schedule_status or '',
'paidAmount': _money(paid),
'arrearsAmount': _money(arrears),
'transfers': [_transfer_dict(item) for item in transfers],
}
def _payment_dict(
payment: BizProtocolSamplePayment,
protocol: BizCollectionProtocol,
party: Optional[BizProtocolPartyCustomer],
schedules: List[BizProtocolPaymentSchedule],
transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]],
sample_meta: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
meta = sample_meta or {}
schedule_rows = [
_period_dict(row, transfers_by_schedule.get(row.id, []))
for row in schedules
]
active_schedule_rows = [
schedule_row
for schedule_row, schedule in zip(schedule_rows, schedules)
if (schedule.schedule_status or '') != 'void'
]
transfers = [
transfer
for schedule in schedules
for transfer in transfers_by_schedule.get(schedule.id, [])
]
periods = payment_crud._build_periods_array(schedules, transfers)
period_by_schedule: Dict[str, Dict[str, Any]] = {}
for period in periods:
if not period:
continue
schedule_id = str(period.get('scheduleId') or '')
period['transfers'] = [
_transfer_dict(row)
for row in transfers_by_schedule.get(schedule_id, [])
]
period_by_schedule[schedule_id] = period
for schedule in schedule_rows:
period = period_by_schedule.get(str(schedule.get('id') or ''), {})
schedule['displayPaidAmount'] = _money(period.get('displayPaidAmount'))
schedule['payDate'] = period.get('payDate') or ''
progress_summary = compute_sample_payment_progress(
schedules=[
{
'scheduleStatus': row.schedule_status or '',
'dueAmount': row.due_amount,
}
for row in schedules
],
transfers=[
{
'payStatus': row.pay_status or '',
'transferAmount': row.transfer_amount,
}
for row in transfers
],
)
paid_total = progress_summary['paidAmount']
remain_total = progress_summary['dueRemainAmount']
receivable_total = sum(
(
Decimal(str(row.due_amount or '0'))
for row in schedules
if (row.schedule_status or '') != 'void'
),
Decimal('0'),
)
progress_code = progress_summary['progressStatus']
progress = {
'yjq': '已缴清',
'bfyj': '部分已缴',
'dfj': '待缴清',
}[progress_code]
return {
'id': payment.id,
'collectionProtocolId': protocol.id,
'protocolNo': protocol.protocol_no or '',
'protocolStatus': protocol.protocol_status or '',
'paymentModelVersion': int(getattr(protocol, 'payment_model_version', None) or 1),
'motherName': (party.mother_name if party else '') or '',
'motherPhone': (party.mother_phone if party else '') or '',
'sampleCode': payment.sample_code or '',
'payMode': payment.pay_mode or '',
'contractAmount': _money(payment.contract_amount),
'signDate': _display_ymd(getattr(protocol, 'register_date', None)),
'storageYears': meta.get('storageYears'),
'sampleTypeName': meta.get('sampleTypeName') or '',
'discountAmount': meta.get('discountAmount'),
'receivableAmount': _money(receivable_total),
'remark': payment.remark or '',
'paidAmount': _money(paid_total),
'remainAmount': _money(remain_total),
'dueRemainAmount': _money(remain_total),
'clearedScheduleCount': progress_summary['clearedScheduleCount'],
'activeScheduleCount': progress_summary['activeScheduleCount'],
'paymentProgress': progress,
'progressStatus': progress_code,
'periods': periods,
'schedules': schedule_rows,
}
def _scoped_payment_query(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
v2_only: bool = False,
):
protocol_conditions = [
BizCollectionProtocol.id
== BizProtocolSamplePayment.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
]
if v2_only:
protocol_conditions.append(BizCollectionProtocol.payment_model_version == 2)
query = (
db.query(
BizProtocolSamplePayment,
BizCollectionProtocol,
BizProtocolPartyCustomer,
)
.join(
BizCollectionProtocol,
and_(*protocol_conditions),
)
.outerjoin(
BizProtocolPartyCustomer,
and_(
BizProtocolPartyCustomer.collection_protocol_id
== BizCollectionProtocol.id,
BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
),
)
.filter(
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
)
)
return apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
def _load_children(
db: Session,
payment_ids: List[str],
tenant_no: str,
) -> Tuple[
Dict[str, List[BizProtocolPaymentSchedule]],
Dict[str, List[BizProtocolPaymentTransfer]],
]:
if not payment_ids:
return {}, {}
schedules = (
db.query(BizProtocolPaymentSchedule)
.filter(
BizProtocolPaymentSchedule.sample_payment_id.in_(payment_ids),
BizProtocolPaymentSchedule.tenant_no == tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
)
.order_by(
BizProtocolPaymentSchedule.order_num.asc(),
BizProtocolPaymentSchedule.schedule_no.asc(),
)
.all()
)
schedule_ids = [row.id for row in schedules]
transfers = []
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.create_time.asc())
.all()
)
schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
for row in schedules:
schedules_by_payment.setdefault(row.sample_payment_id, []).append(row)
for row in transfers:
transfers_by_schedule.setdefault(row.schedule_id, []).append(row)
return schedules_by_payment, transfers_by_schedule
def list_payments(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
pay_mode: Optional[str] = None,
progress_status: Optional[str] = None,
installment_due_date_start: Optional[str] = None,
installment_due_date_end: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
query = _scoped_payment_query(db, ctx=ctx, user_info=user_info)
keyword_value = (keyword or '').strip()
if keyword_value:
like = f'%{keyword_value}%'
query = query.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:
query = query.filter(BizProtocolSamplePayment.pay_mode == mode)
def due_date_value(raw: Optional[str]) -> str:
text = str(raw or '').strip().replace('-', '')
if not text:
return ''
try:
datetime.strptime(text, '%Y%m%d')
except ValueError as exc:
raise ValueError('应缴费时间格式无效,应为 YYYY-MM-DD') from exc
return text
due_start = due_date_value(installment_due_date_start)
due_end = due_date_value(installment_due_date_end)
if due_start or due_end:
due_conditions = [
BizProtocolPaymentSchedule.sample_payment_id
== BizProtocolSamplePayment.id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status != 'void',
]
normalized_due = func.replace(BizProtocolPaymentSchedule.due_date, '-', '')
if due_start:
due_conditions.append(normalized_due >= due_start)
if due_end:
due_conditions.append(normalized_due <= due_end)
query = query.filter(exists().where(and_(*due_conditions)))
progress_code = str(progress_status or '').strip()
if progress_code and progress_code not in {'dfj', 'bfyj', 'yjq'}:
raise ValueError('缴费进度无效')
if progress_code:
schedule_count = (
select(func.count(BizProtocolPaymentSchedule.id))
.where(
BizProtocolPaymentSchedule.sample_payment_id
== BizProtocolSamplePayment.id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status != 'void',
)
.correlate(BizProtocolSamplePayment)
.scalar_subquery()
)
cleared_schedule_count = (
select(func.count(BizProtocolPaymentSchedule.id))
.where(
BizProtocolPaymentSchedule.sample_payment_id
== BizProtocolSamplePayment.id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status == 'cleared',
)
.correlate(BizProtocolSamplePayment)
.scalar_subquery()
)
confirmed_paid = (
select(func.coalesce(func.sum(BizProtocolPaymentTransfer.transfer_amount), 0))
.select_from(BizProtocolPaymentTransfer)
.join(
BizProtocolPaymentSchedule,
and_(
BizProtocolPaymentSchedule.id
== BizProtocolPaymentTransfer.schedule_id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
),
)
.where(
BizProtocolPaymentSchedule.sample_payment_id
== BizProtocolSamplePayment.id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'yjf',
)
.correlate(BizProtocolSamplePayment)
.scalar_subquery()
)
is_cleared = and_(
schedule_count > 0,
cleared_schedule_count == schedule_count,
)
if progress_code == 'yjq':
query = query.filter(is_cleared)
elif progress_code == 'bfyj':
query = query.filter(confirmed_paid > 0, ~is_cleared)
else:
query = query.filter(confirmed_paid <= 0, ~is_cleared)
total = (
query.order_by(None)
.with_entities(func.count(BizProtocolSamplePayment.id))
.scalar()
or 0
)
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
query.order_by(
BizProtocolSamplePayment.create_time.desc(),
BizProtocolSamplePayment.id.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
schedules, transfers = _load_children(
db, [row[0].id for row in rows], ctx.tenant_no
)
sample_meta = _load_sample_meta(db, [row[0] for row in rows], ctx.tenant_no)
return [
_payment_dict(
payment,
protocol,
party,
schedules.get(payment.id, []),
transfers,
sample_meta.get(payment.sample_code or ''),
)
for payment, protocol, party in rows
], int(total)
def get_payment_detail(
db: Session,
*,
payment_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
row = _scoped_payment_query(
db, ctx=ctx, user_info=user_info, v2_only=False
).filter(
BizProtocolSamplePayment.id == payment_id
).first()
if not row:
raise HTTPException(status_code=404, detail='缴费方案不存在或无权访问')
payment, protocol, party = row
schedules, transfers = _load_children(db, [payment.id], ctx.tenant_no)
sample_meta = _load_sample_meta(db, [payment], ctx.tenant_no)
return _payment_dict(
payment,
protocol,
party,
schedules.get(payment.id, []),
transfers,
sample_meta.get(payment.sample_code or ''),
)
def list_payment_chase(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
chase_type: Optional[str] = None,
pay_mode: Optional[str] = None,
sales_user_id: Optional[str] = None,
today: Optional[date] = None,
) -> Tuple[List[Dict[str, Any]], int]:
today_value = today or date.today()
today_key = today_value.strftime('%Y%m%d')
end_key = (today_value + timedelta(days=30)).strftime('%Y%m%d')
normalized_due = func.replace(BizProtocolPaymentSchedule.due_date, '-', '')
dialect_name = db.get_bind().dialect.name
if dialect_name == 'mysql':
parsed_due = func.str_to_date(normalized_due, '%Y%m%d')
month_start = func.str_to_date(
func.concat(func.substr(normalized_due, 1, 6), '01'),
'%Y%m%d',
)
month_number = cast(func.substr(normalized_due, 5, 2), Integer)
day_number = cast(func.substr(normalized_due, 7, 2), Integer)
valid_due = and_(
func.length(normalized_due) == 8,
normalized_due.op('REGEXP')('^[0-9]{8}$'),
month_number.between(1, 12),
day_number.between(1, func.day(func.last_day(month_start))),
func.date_format(parsed_due, '%Y%m%d') == normalized_due,
)
else:
parsed_due = func.date(
func.printf(
'%s-%s-%s',
func.substr(normalized_due, 1, 4),
func.substr(normalized_due, 5, 2),
func.substr(normalized_due, 7, 2),
)
)
valid_due = and_(
func.length(normalized_due) == 8,
func.strftime('%Y%m%d', parsed_due) == normalized_due,
)
paid_subquery = (
db.query(
BizProtocolPaymentTransfer.schedule_id.label('schedule_id'),
func.sum(
case(
(
BizProtocolPaymentTransfer.pay_status == 'yjf',
BizProtocolPaymentTransfer.transfer_amount,
),
else_=Decimal('0'),
)
).label('paid_amount'),
)
.filter(
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
.group_by(BizProtocolPaymentTransfer.schedule_id)
.subquery()
)
paid_amount = func.coalesce(paid_subquery.c.paid_amount, Decimal('0'))
arrears_amount = (
func.coalesce(BizProtocolPaymentSchedule.due_amount, Decimal('0'))
- paid_amount
)
collection_row = aliased(BizProtocolCollectionInfo)
query = (
db.query(
BizProtocolPaymentSchedule,
BizProtocolSamplePayment,
BizCollectionProtocol,
BizProtocolPartyCustomer,
collection_row,
paid_amount.label('paid_amount'),
)
.join(
BizProtocolSamplePayment,
and_(
BizProtocolSamplePayment.id
== BizProtocolPaymentSchedule.sample_payment_id,
BizProtocolSamplePayment.collection_protocol_id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
),
)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
),
)
.join(
collection_row,
and_(
collection_row.collection_protocol_id
== BizCollectionProtocol.id,
collection_row.tenant_no == ctx.tenant_no,
collection_row.del_flag == 0,
),
)
.outerjoin(
BizProtocolPartyCustomer,
and_(
BizProtocolPartyCustomer.collection_protocol_id
== BizCollectionProtocol.id,
BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
),
)
.outerjoin(
paid_subquery,
paid_subquery.c.schedule_id == BizProtocolPaymentSchedule.id,
)
.filter(
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status != 'void',
valid_due,
normalized_due <= end_key,
arrears_amount > 0,
)
)
query = apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
keyword_value = (keyword or '').strip()
if keyword_value:
like = f'%{keyword_value}%'
query = query.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:
query = query.filter(BizProtocolSamplePayment.pay_mode == mode)
sales = (sales_user_id or '').strip()
if sales:
query = query.filter(collection_row.sales_user_id == sales)
kind = (chase_type or '').strip()
if kind == 'overdue':
query = query.filter(normalized_due < today_key)
elif kind == 'upcoming':
query = query.filter(normalized_due >= today_key)
total = (
query.order_by(None)
.with_entities(func.count(BizProtocolPaymentSchedule.id))
.scalar()
or 0
)
page = max(1, page)
page_size = min(max(1, page_size), 100)
candidates = (
query.order_by(
normalized_due.asc(),
BizCollectionProtocol.protocol_no.asc(),
BizProtocolPaymentSchedule.id.asc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
sales_user_ids = {
collection.sales_user_id
for _, _, _, _, collection, _ in candidates
if collection.sales_user_id
}
sales_user_names = {
row.id: row.nick_name or ''
for row in db.query(SysUser).filter(
SysUser.id.in_(sales_user_ids or ['']),
SysUser.tenant_no == ctx.tenant_no,
)
}
rows: List[Dict[str, Any]] = []
for schedule, payment, protocol, party, collection, paid in candidates:
due_date = parse_due_date_ymd(schedule.due_date)
if due_date is None:
continue
paid_value = Decimal(str(paid or 0))
due_value = Decimal(str(schedule.due_amount or 0))
arrears = max(due_value - paid_value, Decimal('0'))
is_overdue = due_date < today_value
rows.append(
{
'scheduleId': schedule.id,
'samplePaymentId': payment.id,
'protocolId': protocol.id,
'protocolNo': protocol.protocol_no or '',
'protocolStatus': protocol.protocol_status or '',
'motherName': (party.mother_name if party else '') or '',
'motherPhone': (party.mother_phone if party else '') or '',
'sampleCode': payment.sample_code or schedule.sample_code or '',
'salesUserId': collection.sales_user_id or '',
'salesUserName': sales_user_names.get(
collection.sales_user_id or '', ''
),
'payMode': payment.pay_mode or '',
'scheduleNo': schedule.schedule_no,
'dueDate': due_date.strftime('%Y-%m-%d'),
'dueAmount': _money(schedule.due_amount),
'paidAmount': _money(paid_value),
'arrearsAmount': _money(arrears),
'chaseStatus': 'overdue' if is_overdue else 'upcoming',
'daysOverdue': (today_value - due_date).days if is_overdue else 0,
'daysUntilDue': (due_date - today_value).days
if not is_overdue
else 0,
'paymentModelVersion': int(protocol.payment_model_version or 1),
}
)
return rows, int(total)
def _scoped_chase_schedule(
db: Session,
*,
schedule_id: str,
ctx: MarketFieldContext,
user_info: dict,
today: Optional[date] = None,
):
row = (
apply_cell_protocol_scope(
db.query(
BizProtocolPaymentSchedule,
BizProtocolSamplePayment,
BizCollectionProtocol,
)
.join(
BizProtocolSamplePayment,
and_(
BizProtocolSamplePayment.id
== BizProtocolPaymentSchedule.sample_payment_id,
BizProtocolSamplePayment.collection_protocol_id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
),
)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
),
)
.filter(
BizProtocolPaymentSchedule.id == schedule_id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status != 'void',
),
db,
ctx,
user_info=user_info,
)
.first()
)
if not row:
raise HTTPException(status_code=404, detail='催缴期次不存在或无权访问')
schedule, payment, protocol = row
due_date = parse_due_date_ymd(schedule.due_date)
confirmed_paid = (
db.query(func.coalesce(func.sum(BizProtocolPaymentTransfer.transfer_amount), 0))
.filter(
BizProtocolPaymentTransfer.schedule_id == schedule.id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'yjf',
)
.scalar()
or Decimal('0')
)
arrears = max(
Decimal(str(schedule.due_amount or 0)) - Decimal(str(confirmed_paid or 0)),
Decimal('0'),
)
if due_date is None or not is_chase_candidate(
due_date=due_date,
today=today or date.today(),
arrears=arrears,
):
raise HTTPException(status_code=404, detail='催缴期次不存在或无权访问')
return schedule, payment, protocol
def get_payment_chase_detail(
db: Session,
*,
schedule_id: str,
view: str,
ctx: MarketFieldContext,
user_info: dict,
today: Optional[date] = None,
) -> Dict[str, Any]:
schedule, payment, protocol = _scoped_chase_schedule(
db,
schedule_id=schedule_id,
ctx=ctx,
user_info=user_info,
today=today,
)
target_view = str(view or '').strip()
if target_view == 'payment':
detail = get_payment_detail(
db,
payment_id=payment.id,
ctx=ctx,
user_info=user_info,
)
target_transfers = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.schedule_id == schedule.id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
.order_by(BizProtocolPaymentTransfer.create_time.asc())
.all()
)
target_detail = _payment_dict(
payment,
protocol,
None,
[schedule],
{schedule.id: target_transfers},
)
for key in (
'receivableAmount',
'paidAmount',
'remainAmount',
'dueRemainAmount',
'clearedScheduleCount',
'activeScheduleCount',
'paymentProgress',
'progressStatus',
'periods',
'schedules',
):
detail[key] = target_detail[key]
elif target_view == 'protocol':
from app.services import market_protocol
detail = market_protocol.get_protocol(
db,
source=market_protocol.SOURCE_CELL,
protocol_id=protocol.id,
ctx=ctx,
user_info=user_info,
)
else:
raise ValueError('催缴详情类型无效')
detail['chaseView'] = target_view
detail['targetScheduleId'] = schedule.id
detail['allowedActions'] = []
return detail
def _scoped_schedule(
db: Session,
*,
schedule_id: str,
ctx: MarketFieldContext,
user_info: dict,
):
query = (
db.query(BizProtocolPaymentSchedule)
.join(
BizProtocolSamplePayment,
and_(
BizProtocolSamplePayment.id
== BizProtocolPaymentSchedule.sample_payment_id,
BizProtocolSamplePayment.collection_protocol_id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
),
)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
),
)
.filter(
BizProtocolPaymentSchedule.id == schedule_id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status != 'void',
)
)
query = apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
row = query.populate_existing().with_for_update().first()
if not row:
raise HTTPException(status_code=404, detail='应缴批次不存在或无权访问')
return row
def _scoped_transfer(
db: Session,
*,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
):
parent = (
db.query(
BizProtocolPaymentTransfer.schedule_id,
BizProtocolPaymentTransfer.sample_payment_id,
BizProtocolPaymentTransfer.collection_protocol_id,
)
.filter(
BizProtocolPaymentTransfer.id == transfer_id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
.first()
)
if not parent:
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
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 == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
),
)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id == parent.collection_protocol_id,
BizCollectionProtocol.id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
),
)
.filter(
BizProtocolPaymentSchedule.id == parent.schedule_id,
BizProtocolPaymentSchedule.sample_payment_id
== parent.sample_payment_id,
BizProtocolPaymentSchedule.collection_protocol_id
== parent.collection_protocol_id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
)
)
schedule_query = apply_cell_protocol_scope(
schedule_query, db, ctx, user_info=user_info
)
schedule = schedule_query.populate_existing().with_for_update().first()
if not schedule:
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
row = (
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 == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
.populate_existing()
.with_for_update()
.first()
)
if not row:
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
return row
def _editable_fields(fields: Dict[str, Any]) -> Dict[str, Any]:
aliases = {
'transfer_amount': 'transferAmount',
'transfer_time': 'transferTime',
'transfer_type': 'transferType',
'transfer_attachments': 'transferAttachments',
'remark': 'remark',
}
result: Dict[str, Any] = {}
for python_name, alias in aliases.items():
if python_name in fields:
result[alias] = fields[python_name]
elif alias in fields:
result[alias] = fields[alias]
amount = result.get('transferAmount')
if amount is not None and Decimal(str(amount)) <= 0:
raise ValueError('转账金额必须大于 0')
if 'transferTime' in result and result['transferTime'] not in (None, ''):
value = result['transferTime']
result['transferTime'] = value if isinstance(value, datetime) else parse_datetime(str(value))
if 'transferType' in result:
result['transferType'] = str(result['transferType'] or '').strip() or None
if 'transferAttachments' in result:
result['transferAttachments'] = str(
result['transferAttachments'] or ''
).strip() or None
if 'remark' in result:
result['remark'] = str(result['remark'] or '').strip() or None
return result
def create_transfer(
db: Session,
*,
schedule_id: str,
fields: Dict[str, Any],
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
schedule = _scoped_schedule(
db,
schedule_id=schedule_id,
ctx=ctx,
user_info=user_info,
)
confirmed_paid = (
db.query(
func.coalesce(func.sum(BizProtocolPaymentTransfer.transfer_amount), 0)
)
.filter(
BizProtocolPaymentTransfer.schedule_id == schedule.id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'yjf',
)
.scalar()
or Decimal('0')
)
arrears = max(
Decimal(str(schedule.due_amount or 0))
- Decimal(str(confirmed_paid or 0)),
Decimal('0'),
)
if arrears <= 0:
raise ValueError('当前应缴批次无欠缴金额')
values = _editable_fields(fields)
row = payment_crud.add_transfer(
db,
schedule_id=schedule_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
transfer_amount=values.get('transferAmount'),
transfer_time=values.get('transferTime'),
transfer_type=values.get('transferType'),
transfer_attachments=values.get('transferAttachments'),
remark=values.get('remark'),
create_dept_id=_current_dept_id(user_info),
commit=False,
)
if 'transferAttachments' in values:
row.transfer_attachments = validate_attachment_refs(
db,
values['transferAttachments'],
module='payment',
ctx=ctx,
creating=True,
business_id=row.id,
)
db.add(row)
db.flush()
result = _transfer_dict(row)
db.commit()
return result
except Exception:
db.rollback()
raise
def update_transfer(
db: Session,
*,
transfer_id: str,
fields: Dict[str, Any],
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
row = _scoped_transfer(
db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
)
if (row.pay_status or '') != 'dfj':
raise ValueError('仅待缴费草稿状态可编辑')
values = _editable_fields(fields)
if 'transferAttachments' in values:
values['transferAttachments'] = validate_attachment_refs(
db,
values['transferAttachments'],
module='payment',
ctx=ctx,
creating=False,
existing_refs=row.transfer_attachments,
business_id=row.id,
)
if 'transferAmount' in values:
row.transfer_amount = Decimal(str(values['transferAmount'])) if values['transferAmount'] is not None else None
if 'transferTime' in values:
row.transfer_time = values['transferTime']
if 'transferType' in values:
row.transfer_type = values['transferType']
if 'transferAttachments' in values:
row.transfer_attachments = values['transferAttachments']
if 'remark' in values:
row.remark = values['remark']
row.update_by = ctx.user_id
row.update_time = datetime.now()
db.add(row)
db.commit()
db.refresh(row)
return _transfer_dict(row)
except Exception:
db.rollback()
raise
def submit_transfer(
db: Session,
*,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
row = _scoped_transfer(
db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
)
if (row.pay_status or '') != 'dfj':
raise ValueError('当前状态不可提交财务确认')
if row.transfer_amount is None or Decimal(str(row.transfer_amount)) <= 0:
raise ValueError('请填写转账金额')
if not (row.transfer_type or '').strip():
raise ValueError('请选择转账方式')
now = datetime.now()
changed = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.id == transfer_id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'dfj',
)
.update(
{
BizProtocolPaymentTransfer.pay_status: 'jfqrz',
BizProtocolPaymentTransfer.update_by: ctx.user_id,
BizProtocolPaymentTransfer.update_time: now,
},
synchronize_session=False,
)
)
if changed != 1:
raise ValueError('转账状态已变化,请刷新后重试')
db.commit()
db.expire(row)
return _transfer_dict(row)
except Exception:
db.rollback()
raise
def delete_transfer(
db: Session,
*,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, str]:
try:
row = _scoped_transfer(
db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
)
if (row.pay_status or '') != 'dfj':
raise ValueError('当前状态不可删除转账记录')
changed = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.id == transfer_id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'dfj',
)
.update(
{
BizProtocolPaymentTransfer.del_flag: 1,
BizProtocolPaymentTransfer.update_by: ctx.user_id,
BizProtocolPaymentTransfer.update_time: datetime.now(),
},
synchronize_session=False,
)
)
if changed != 1:
raise ValueError('转账状态已变化,请刷新后重试')
db.commit()
return {'id': transfer_id}
except Exception:
db.rollback()
raise
def withdraw_transfer(
db: Session,
*,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
row = _scoped_transfer(
db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
)
if (row.pay_status or '') != 'jfqrz':
raise ValueError('仅付款确认中可撤回')
now = datetime.now()
changed = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.id == transfer_id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'jfqrz',
)
.update(
{
BizProtocolPaymentTransfer.pay_status: 'dfj',
BizProtocolPaymentTransfer.update_by: ctx.user_id,
BizProtocolPaymentTransfer.update_time: now,
},
synchronize_session=False,
)
)
if changed != 1:
raise ValueError('转账状态已变化,请刷新后重试')
db.commit()
db.expire(row)
return _transfer_dict(row)
except Exception:
db.rollback()
raise