from __future__ import annotations import base64 import logging from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal, InvalidOperation from typing import Any, Callable, Iterable import requests from django.conf import settings from django.db import transaction from basic_info import models as basic_models from . import models as business_models from . import services as business_services from .amounts import calculate_settlement_amount, normalize_money_amount logger = logging.getLogger(__name__) class ExternalFinanceSyncError(RuntimeError): pass class ExternalFinanceSyncConflictError(ExternalFinanceSyncError): pass @dataclass(slots=True) class ExternalCustomerRef: customer_id: str customer_name: str class HaoBuYeFinanceClient: def __init__( self, *, base_url: str | None = None, authorization: str | None = None, timeout_seconds: float | None = None, session: requests.Session | None = None, ): self.base_url = str(base_url or getattr(settings, 'HAOBUYE_API_BASE_URL', '') or '').rstrip('/') self.authorization = str( authorization or getattr(settings, 'HAOBUYE_API_AUTHORIZATION', '') or '' ).strip() self.timeout_seconds = float( timeout_seconds if timeout_seconds is not None else getattr(settings, 'HAOBUYE_API_TIMEOUT_SECONDS', 30.0) ) self.session = session or requests.Session() if not self.base_url: raise ExternalFinanceSyncError('未配置 HAOBUYE_API_BASE_URL') if not self.authorization: raise ExternalFinanceSyncError('未配置 HAOBUYE_API_AUTHORIZATION') def list_customers(self, *, cursor_id: str | None = None) -> dict[str, Any]: params: dict[str, str] = {} if cursor_id: params['cursor_id'] = str(cursor_id).strip() payload = self._request_json('/api/v1/customers', params=params) if not isinstance(payload.get('customers', []), list): raise ExternalFinanceSyncError('外部 customers 接口返回格式非法:customers 不是数组') return payload def fetch_customer_finance( self, *, customer_name: str, include_adjustments: bool = True, include_cash_movement: bool = True, ) -> dict[str, Any]: normalized_name = str(customer_name or '').strip() if not normalized_name: raise ExternalFinanceSyncError('customer_name 不能为空') customer_name_b64 = base64.urlsafe_b64encode(normalized_name.encode('utf-8')).decode('ascii').rstrip('=') payload = self._request_json( '/api/v1/finance/by-customer', params={ 'customer_name_b64': customer_name_b64, 'record_types': 'receipt,refund,sale_discount', 'include_cash_movement': str(include_cash_movement).lower(), 'include_adjustments': str(include_adjustments).lower(), }, ) return payload def fetch_i_sale_by_customer( self, *, customer_id: str, category: str, ) -> dict[str, Any]: normalized_customer_id = str(customer_id or '').strip() normalized_category = str(category or '').strip() if not normalized_customer_id: raise ExternalFinanceSyncError('customer_id 不能为空') if normalized_category not in { business_models.ExternalCustomerStatementCategoryEnum.SALE, business_models.ExternalCustomerStatementCategoryEnum.SALE_RETURN, }: raise ExternalFinanceSyncError(f'不支持的 i_sale category: {normalized_category}') return self._request_json( '/api/v1/i-sale/by-customer', params={ 'customer_id': normalized_customer_id, 'category': normalized_category, }, ) def _request_json(self, path: str, *, params: dict[str, str] | None = None) -> dict[str, Any]: url = f'{self.base_url}{path}' try: response = self.session.get( url, headers={'Authorization': self.authorization}, params=params, timeout=self.timeout_seconds, ) except requests.RequestException as exc: raise ExternalFinanceSyncError(f'请求外部接口失败: {exc}') from exc if response.status_code >= 400: try: payload = response.json() except ValueError: payload = {} # 404 且返回了 status=not_found 的 JSON 视为正常业务响应(如客户无退货单), # 由调用方根据 status 字段决定后续逻辑,而不是在此处抛异常中断整个同步。 if response.status_code == 404 and isinstance(payload, dict) and payload.get('status') == 'not_found': return payload message = payload.get('message') or payload.get('error') or f'外部接口异常状态码: {response.status_code}' raise ExternalFinanceSyncError(str(message)) try: payload = response.json() except ValueError as exc: raise ExternalFinanceSyncError('外部接口返回格式非法:非 JSON object') from exc if not isinstance(payload, dict): raise ExternalFinanceSyncError('外部接口返回格式非法:非 JSON object') return payload def sync_customer_finance( *, customer_name: str, operator: basic_models.Employee, client: HaoBuYeFinanceClient | None = None, allow_create_customer: bool = True, dry_run: bool = False, force_update: bool = False, include_adjustments: bool = True, include_cash_movement: bool = True, progress_callback: Callable[[str], None] | None = None, progress_every: int = 200, ) -> dict[str, Any]: merchant = _ensure_active_operator(operator) _emit_progress(progress_callback, f'开始同步客户: {customer_name}') finance_client = client or HaoBuYeFinanceClient() payload = finance_client.fetch_customer_finance( customer_name=customer_name, include_adjustments=include_adjustments, include_cash_movement=include_cash_movement, ) status_value = str(payload.get('status') or '').strip() normalized_name = str(payload.get('customer_name') or customer_name or '').strip() external_customer_id = _extract_external_customer_id(payload) summary: dict[str, Any] = { 'customer_name': normalized_name, 'external_customer_id': external_customer_id, 'status': status_value or 'active', 'receipts_seen': 0, 'refunds_seen': 0, 'sales_seen': 0, 'sale_returns_seen': 0, 'created_count': 0, 'skipped_existing_count': 0, 'skipped_zero_settlement_count': 0, 'external_business_created_count': 0, 'external_business_skipped_existing_count': 0, 'dry_run_count': 0, 'created_receipt_ids': [], 'created_external_business_ids': [], 'skipped_external_ids': [], } if status_value == 'not_found': return summary customer = _find_or_create_customer( merchant=merchant, customer_name=normalized_name, external_customer_id=external_customer_id, operator=operator, allow_create_customer=allow_create_customer, dry_run=dry_run, ) receipts = payload.get('receipts') or [] refunds = payload.get('refunds') or [] if not isinstance(receipts, list) or not isinstance(refunds, list): raise ExternalFinanceSyncError('外部 finance 接口返回格式非法:receipts/refunds 不是数组') summary['receipts_seen'] = len(receipts) summary['refunds_seen'] = len(refunds) _emit_progress( progress_callback, f'财务记录已获取: receipts={summary["receipts_seen"]}, refunds={summary["refunds_seen"]}', ) for index, record in enumerate(receipts, start=1): result = _sync_external_receipt_record( merchant=merchant, customer=customer, operator=operator, record=record, record_kind='receipt', dry_run=dry_run, ) _merge_sync_result(summary, result) if index == 1 or index % progress_every == 0 or index == len(receipts): _emit_progress( progress_callback, f'收款同步进度: {index}/{len(receipts)}', ) # NOTE: refunds (XT% 退款记录) 不再创建 ReceiptOrder。 # 退货的减欠效果完全由 ExternalCustomerStatementOrder(category=sale_return) 承担。 # 之前的实现会把 refund 同时写成 ReceiptOrder,导致退货金额被双重计入, # 与 ERP 对账结果不一致(详见 docs/DEBT_ANALYSIS_SUMMARY_2026-05-18.md 第四节)。 if refunds: _emit_progress( progress_callback, f'跳过 refunds 桶({len(refunds)} 条),退货效果由 sale_return 业务依据承担', ) if external_customer_id: _emit_progress(progress_callback, f'开始同步外部业务依据: customer_id={external_customer_id}, category=sale') sales_result = sync_customer_external_statement_orders( customer=customer, external_customer_id=external_customer_id, operator=operator, client=finance_client, category=business_models.ExternalCustomerStatementCategoryEnum.SALE, dry_run=dry_run, force_update=force_update, progress_callback=progress_callback, progress_every=progress_every, ) _merge_external_business_sync_result(summary, sales_result) _emit_progress(progress_callback, f'开始同步外部业务依据: customer_id={external_customer_id}, category=sale_return') sale_return_result = sync_customer_external_statement_orders( customer=customer, external_customer_id=external_customer_id, operator=operator, client=finance_client, category=business_models.ExternalCustomerStatementCategoryEnum.SALE_RETURN, dry_run=dry_run, force_update=force_update, progress_callback=progress_callback, progress_every=progress_every, ) _merge_external_business_sync_result(summary, sale_return_result) # 处理 sale_discount 数据:从 F_Skd 的 XS% 行获取真实的销售折扣 ZkJinE, # 更新到对应的 ExternalCustomerStatementOrder.zk_amount 中。 # I_Sale 表的 ZkJinE 始终为 0,真实折扣只存在于 F_Skd 表。 sale_discounts = payload.get('sale_discounts') or [] if sale_discounts and isinstance(sale_discounts, list) and customer: _apply_sale_discount_zk( merchant=merchant, customer=customer, sale_discounts=sale_discounts, progress_callback=progress_callback, ) _emit_progress(progress_callback, f'客户同步完成: {customer_name}') return summary def sync_customer_external_statement_orders( *, customer: basic_models.Customer, external_customer_id: str, operator: basic_models.Employee, client: HaoBuYeFinanceClient | None = None, category: str, dry_run: bool = False, force_update: bool = False, progress_callback: Callable[[str], None] | None = None, progress_every: int = 200, ) -> dict[str, Any]: merchant = _ensure_active_operator(operator) if customer.merchant_id != merchant.id: raise ExternalFinanceSyncError('customer 与 operator 不属于同一 merchant') finance_client = client or HaoBuYeFinanceClient() payload = finance_client.fetch_i_sale_by_customer(customer_id=external_customer_id, category=category) status_value = str(payload.get('status') or '').strip() records = payload.get('records') or [] if not isinstance(records, list): raise ExternalFinanceSyncError('外部 i_sale by customer 接口返回格式非法:records 不是数组') summary = { 'category': category, 'seen_count': len(records), 'created_count': 0, 'skipped_existing_count': 0, 'created_ids': [], } if status_value == 'not_found': _emit_progress(progress_callback, f'外部业务依据未命中: category={category}, customer_id={external_customer_id}') return summary grouped_records = _group_external_i_sale_records(records) summary['seen_count'] = len(grouped_records) _emit_progress( progress_callback, f'外部业务依据已获取: category={category}, raw_records={len(records)}, grouped_orders={summary["seen_count"]}', ) for index, (external_source_id, grouped) in enumerate(grouped_records.items(), start=1): result = _sync_external_statement_order_group( merchant=merchant, customer=customer, external_customer_id=external_customer_id, external_source_id=external_source_id, category=category, grouped_records=grouped, dry_run=dry_run, force_update=force_update, ) summary['created_count'] += result['created_count'] summary['skipped_existing_count'] += result['skipped_existing_count'] summary['created_ids'].extend(result['created_ids']) if index == 1 or index % progress_every == 0 or index == len(grouped_records): _emit_progress( progress_callback, ( f'外部业务依据同步进度: category={category}, {index}/{len(grouped_records)}, ' f'created={summary["created_count"]}, skipped={summary["skipped_existing_count"]}' ), ) return summary def sync_customer_finance_batch( *, operator: basic_models.Employee, cursor_id: str | None = None, client: HaoBuYeFinanceClient | None = None, allow_create_customer: bool = True, dry_run: bool = False, include_adjustments: bool = True, include_cash_movement: bool = True, ) -> dict[str, Any]: _ensure_active_operator(operator) finance_client = client or HaoBuYeFinanceClient() customers_payload = finance_client.list_customers(cursor_id=cursor_id) raw_customers = customers_payload.get('customers') or [] customer_refs = [ ExternalCustomerRef( customer_id=str(item.get('customer_id') or '').strip(), customer_name=str(item.get('customer_name') or '').strip(), ) for item in raw_customers if str(item.get('customer_name') or '').strip() ] results = [] for customer_ref in customer_refs: results.append( sync_customer_finance( customer_name=customer_ref.customer_name, operator=operator, client=finance_client, allow_create_customer=allow_create_customer, dry_run=dry_run, include_adjustments=include_adjustments, include_cash_movement=include_cash_movement, ) ) return { 'mode': 'batch_customer_finance', 'cursor_id': customers_payload.get('cursor_id'), 'next_cursor_id': customers_payload.get('next_cursor_id'), 'count': len(customer_refs), 'results': results, } def _ensure_active_operator(operator: basic_models.Employee) -> basic_models.Merchant: if not operator: raise ExternalFinanceSyncError('operator 不能为空') if operator.status != basic_models.EmployeeStatusEnum.ACTIVE: raise ExternalFinanceSyncError('operator 必须为在职员工') if not operator.merchant_id: raise ExternalFinanceSyncError('operator 未绑定 merchant') return operator.merchant def _extract_external_customer_id(payload: dict[str, Any]) -> str: customer_ids = payload.get('customer_ids') or [] if isinstance(customer_ids, list) and customer_ids: return str(customer_ids[0] or '').strip() return '' def _find_or_create_customer( *, merchant: basic_models.Merchant, customer_name: str, external_customer_id: str, operator: basic_models.Employee, allow_create_customer: bool, dry_run: bool, ): customer = basic_models.Customer.objects.filter( merchant=merchant, name=customer_name, ).order_by('id').first() if customer: return customer if not allow_create_customer: raise ExternalFinanceSyncError(f'客户不存在且未允许自动创建: {customer_name}') if dry_run: return basic_models.Customer( merchant=merchant, name=customer_name, created_by=operator, description=f'外部客户ID: {external_customer_id}' if external_customer_id else '', ) return basic_models.Customer.objects.create( merchant=merchant, name=customer_name, created_by=operator, description=f'外部客户ID: {external_customer_id}' if external_customer_id else '', ) def _sync_external_receipt_record( *, merchant: basic_models.Merchant, customer, operator: basic_models.Employee, record: dict[str, Any], record_kind: str, dry_run: bool, ) -> dict[str, Any]: if not isinstance(record, dict): raise ExternalFinanceSyncError('外部 finance record 不是对象') normalized = _normalize_external_receipt_record(record=record, record_kind=record_kind) existing = business_models.ReceiptOrder.objects.filter( merchant=merchant, external_source_id=normalized['external_source_id'], is_external_source=True, ).select_related('customer').first() if existing: if not _is_equivalent_receipt_order(existing=existing, customer=customer, normalized=normalized): raise ExternalFinanceSyncConflictError( f'外部记录 {normalized["external_source_id"]} 已存在,但本地字段与外部不一致' ) return { 'created_count': 0, 'skipped_existing_count': 1, 'skipped_zero_settlement_count': 0, 'dry_run_count': 0, 'created_receipt_ids': [], 'skipped_external_ids': [normalized['external_source_id']], } if dry_run: return { 'created_count': 0, 'skipped_existing_count': 0, 'skipped_zero_settlement_count': 0, 'dry_run_count': 1, 'created_receipt_ids': [], 'skipped_external_ids': [], } with transaction.atomic(): order = business_models.ReceiptOrder.objects.create( merchant=merchant, customer=customer, receipt_date=normalized['receipt_date'], amount=normalized['amount'], discount_amount=normalized['discount_amount'], bank_account=None, markup=normalized['markup'], operator=operator, status=business_models.ReceiptOrderStatusEnum.PENDING, is_external_source=True, external_source_id=normalized['external_source_id'], remarks=normalized['remarks'], ) approved = business_services.review_receipt_order( receipt_order=order, target_status=business_models.ReceiptOrderStatusEnum.APPROVED, reviewed_by=operator, ) return { 'created_count': 1, 'skipped_existing_count': 0, 'skipped_zero_settlement_count': 0, 'dry_run_count': 0, 'created_receipt_ids': [approved.id], 'skipped_external_ids': [], } def _normalize_external_receipt_record(*, record: dict[str, Any], record_kind: str) -> dict[str, Any]: external_source_id = str(record.get('BianHaoID') or '').strip() if not external_source_id: raise ExternalFinanceSyncError('外部 finance record 缺少 BianHaoID') customer_name = str(record.get('KhName') or '').strip() markup = str(record.get('JieSunFS') or '').strip() or None receipt_date = _parse_external_date(record.get('RiQi') or record.get('KdRiQi')) if record_kind == 'receipt': amount = normalize_money_amount( _to_decimal(record.get('FkJinE'), field_name='FkJinE'), field_name='FkJinE', ) discount_amount = normalize_money_amount( _to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0')), field_name='ZkJinE', ) elif record_kind == 'refund': amount = normalize_money_amount( _to_decimal(record.get('YfJinE'), field_name='YfJinE'), field_name='YfJinE', ) discount_amount = Decimal('0') else: raise ExternalFinanceSyncError(f'不支持的 record_kind: {record_kind}') return { 'external_source_id': external_source_id, 'receipt_date': receipt_date, 'amount': amount, 'discount_amount': discount_amount, 'settlement_amount': calculate_settlement_amount( amount=amount, discount_amount=discount_amount, ), 'customer_name': customer_name, 'markup': markup, 'remarks': _build_external_remarks(record=record, record_kind=record_kind), } def _build_external_remarks(*, record: dict[str, Any], record_kind: str) -> str: lines = [f'外部财务同步: {record_kind}'] external_customer_id = str(record.get('KhID') or '').strip() if external_customer_id: lines.append(f'外部客户ID: {external_customer_id}') summary = str(record.get('ZhaiYao') or '').strip() if summary: lines.append(f'摘要: {summary}') note = str(record.get('BeiZhu') or '').strip() if note: lines.append(f'备注: {note}') return '\n'.join(lines) def _parse_external_date(value: Any) -> date: text = str(value or '').strip() if not text: raise ExternalFinanceSyncError('外部 finance record 缺少日期字段') try: parsed = datetime.fromisoformat(text.replace('Z', '+00:00')) except ValueError as exc: raise ExternalFinanceSyncError(f'外部日期格式非法: {text}') from exc return parsed.date() def _to_decimal(value: Any, *, field_name: str, default: Decimal | None = None) -> Decimal: if value in (None, ''): if default is not None: return default raise ExternalFinanceSyncError(f'外部字段 {field_name} 不能为空') try: return Decimal(str(value)) except (InvalidOperation, TypeError, ValueError) as exc: raise ExternalFinanceSyncError(f'外部字段 {field_name} 不是合法数字: {value!r}') from exc def _is_equivalent_receipt_order(*, existing: business_models.ReceiptOrder, customer, normalized: dict[str, Any]) -> bool: customer_id = getattr(customer, 'id', None) return all( [ existing.customer_id == customer_id, existing.receipt_date == normalized['receipt_date'], existing.amount == normalized['amount'], existing.discount_amount == normalized['discount_amount'], existing.status == business_models.ReceiptOrderStatusEnum.APPROVED, ] ) def _merge_sync_result(summary: dict[str, Any], result: dict[str, Any]) -> None: summary['created_count'] += int(result.get('created_count') or 0) summary['skipped_existing_count'] += int(result.get('skipped_existing_count') or 0) summary['skipped_zero_settlement_count'] += int(result.get('skipped_zero_settlement_count') or 0) summary['dry_run_count'] += int(result.get('dry_run_count') or 0) summary['created_receipt_ids'].extend(result.get('created_receipt_ids') or []) summary['skipped_external_ids'].extend(result.get('skipped_external_ids') or []) def _merge_external_business_sync_result(summary: dict[str, Any], result: dict[str, Any]) -> None: category = result.get('category') seen_count = int(result.get('seen_count') or 0) if category == business_models.ExternalCustomerStatementCategoryEnum.SALE: summary['sales_seen'] += seen_count elif category == business_models.ExternalCustomerStatementCategoryEnum.SALE_RETURN: summary['sale_returns_seen'] += seen_count summary['external_business_created_count'] += int(result.get('created_count') or 0) summary['external_business_skipped_existing_count'] += int(result.get('skipped_existing_count') or 0) summary['created_external_business_ids'].extend(result.get('created_ids') or []) def _group_external_i_sale_records(records: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: grouped: dict[str, list[dict[str, Any]]] = {} for record in records: if not isinstance(record, dict): raise ExternalFinanceSyncError('外部 i_sale record 不是对象') external_source_id = str(record.get('BianHaoID') or '').strip() if not external_source_id: raise ExternalFinanceSyncError('外部 i_sale record 缺少 BianHaoID') grouped.setdefault(external_source_id, []).append(record) return grouped def _sync_external_statement_order_group( *, merchant: basic_models.Merchant, customer: basic_models.Customer, external_customer_id: str, external_source_id: str, category: str, grouped_records: list[dict[str, Any]], dry_run: bool, force_update: bool = False, ) -> dict[str, Any]: normalized = _normalize_external_statement_order_group( customer=customer, external_customer_id=external_customer_id, category=category, external_source_id=external_source_id, grouped_records=grouped_records, ) existing = business_models.ExternalCustomerStatementOrder.objects.filter( merchant=merchant, category=category, external_source_id=external_source_id, ).first() if existing: if _is_equivalent_external_statement_order(existing=existing, normalized=normalized): return {'created_count': 0, 'skipped_existing_count': 1, 'updated_count': 0, 'created_ids': []} if not force_update: raise ExternalFinanceSyncConflictError( f'外部业务记录 {external_source_id} 已存在,但本地字段与外部不一致' ) # force_update: 用新数据覆盖已有记录 update_fields = [ 'total_amount', 'sf_amount', 'zk_amount', 'occurred_at', 'recorded_at', 'settlement_method', 'remarks', 'items_payload', 'extra_payload', ] for field in update_fields: if field in normalized: setattr(existing, field, normalized[field]) existing.save(update_fields=[*update_fields, 'updated_at']) return {'created_count': 0, 'skipped_existing_count': 0, 'updated_count': 1, 'created_ids': []} if dry_run: return {'created_count': 0, 'skipped_existing_count': 0, 'updated_count': 0, 'created_ids': []} created = business_models.ExternalCustomerStatementOrder.objects.create(**normalized) return {'created_count': 1, 'skipped_existing_count': 0, 'updated_count': 0, 'created_ids': [created.id]} def _normalize_external_statement_order_group( *, customer: basic_models.Customer, external_customer_id: str, category: str, external_source_id: str, grouped_records: list[dict[str, Any]], ) -> dict[str, Any]: first_record = grouped_records[0] occurred_at = _parse_external_date(first_record.get('RiQi') or first_record.get('KdRiQi')) recorded_at = _parse_external_datetime(first_record.get('KdRiQi')) remarks_lines = [] items_payload = [] total_amount = Decimal('0') sf_amount = Decimal('0') zk_amount = Decimal('0') # SfJinE 是单据头级别字段(每行重复),只取首条记录的值(按 BianHaoID 去重) sf_amount = abs(_to_decimal(first_record.get('SfJinE'), field_name='SfJinE', default=Decimal('0'))) # ZkJinE 不去重,每行独立求和 for record in grouped_records: # JinE 不取绝对值:同一订单内可能有负数明细行(如冲减行),应保留原始正负 # 最终 total_amount 取绝对值(退货单整体为负,销售单整体为正) amount_value = _to_decimal(record.get('JinE'), field_name='JinE', default=Decimal('0')) price_value = abs(_to_decimal(record.get('DanJia'), field_name='DanJia', default=Decimal('0'))) quantity_value = abs(_to_decimal(record.get('ShuLiang'), field_name='ShuLiang', default=Decimal('0'))) rolls_value = abs(_to_decimal(record.get('JianShu'), field_name='JianShu', default=Decimal('0'))) zk_value = abs(_to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0'))) total_amount += amount_value zk_amount += zk_value external_product_id = str(record.get('HpID') or '').strip() product_name = str(record.get('HpName') or '').strip() or external_product_id color = str(record.get('YanSe') or '').strip() spec = str(record.get('SeHao') or '').strip() unit = str(record.get('JiJiaDW') or '').strip() item_payload = { 'product_id': None, 'product_name': product_name, 'quantity': _decimal_to_string(quantity_value), 'price': _decimal_to_string(price_value), 'unit': unit, 'color': color, 'spec': spec, 'quantity_of_rolls': [], 'num_of_rolls': int(rolls_value) if rolls_value == rolls_value.to_integral_value() else 0, 'external_sub_id': record.get('SubID'), 'external_product_id': external_product_id, } items_payload.append(item_payload) for note_key in ('BeiZhu', 'BeiZhuC', 'BeiZhuD', 'MeoD'): note = str(record.get(note_key) or '').strip() if note: remarks_lines.append(f'{note_key}: {note}') return { 'merchant': customer.merchant, 'customer': customer, 'category': category, 'external_customer_id': external_customer_id, 'external_source_id': external_source_id, 'occurred_at': occurred_at, 'recorded_at': recorded_at, 'settlement_method': str(first_record.get('JieSunFS') or '').strip(), 'total_amount': abs(total_amount), 'sf_amount': sf_amount, 'zk_amount': zk_amount, 'remarks': '\n'.join(dict.fromkeys(remarks_lines)), 'items_payload': items_payload, 'extra_payload': { 'raw_count': len(grouped_records), 'dan_type': str(first_record.get('DanType') or '').strip(), }, } def _is_equivalent_external_statement_order( *, existing: business_models.ExternalCustomerStatementOrder, normalized: dict[str, Any], ) -> bool: return all( [ existing.customer_id == normalized['customer'].id, existing.external_customer_id == normalized['external_customer_id'], existing.occurred_at == normalized['occurred_at'], existing.recorded_at == normalized['recorded_at'], existing.total_amount == normalized['total_amount'], existing.sf_amount == normalized.get('sf_amount', Decimal('0')), existing.zk_amount == normalized.get('zk_amount', Decimal('0')), existing.items_payload == normalized['items_payload'], (existing.remarks or '') == (normalized['remarks'] or ''), ] ) def _apply_sale_discount_zk( *, merchant: basic_models.Merchant, customer: basic_models.Customer, sale_discounts: list[dict[str, Any]], progress_callback: Callable[[str], None] | None = None, ) -> int: """ 从 sale_discount 数据(F_Skd 的 XS% 行)中提取 ZkJinE 和 YfJinE, 更新到对应的 ExternalCustomerStatementOrder.zk_amount 和 total_amount。 关键修正:I_Sale.SfJinE 始终为 0(数据源缺陷),导致从 I_Sale.JinE 计算的 total_amount 未扣除实付部分。F_Skd XS% 的 YfJinE 已经扣除了实付,是正确的 欠款基数,因此用它覆盖 total_amount。 返回更新的记录数。 """ # 按 BianHaoID 聚合折扣和应付金额 zk_by_source: dict[str, Decimal] = {} yf_by_source: dict[str, Decimal] = {} for record in sale_discounts: if not isinstance(record, dict): continue source_id = str(record.get('BianHaoID') or '').strip() if not source_id: continue zk_value = abs(_to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0'))) # YfJinE 不取 abs():负值代表退款/冲减,应保留原始正负 yf_value = _to_decimal(record.get('YfJinE'), field_name='YfJinE', default=Decimal('0')) if zk_value: zk_by_source[source_id] = zk_by_source.get(source_id, Decimal('0')) + zk_value # YfJinE 是每个销售单的正确欠款金额(已扣除实付 SfJinE) # 一个 BianHaoID 通常只有一条 F_Skd XS% 记录,但为安全起见做聚合 # 注意:不过滤零值,因为 YfJinE=0 也是有效数据(表示全额实付) yf_by_source[source_id] = yf_by_source.get(source_id, Decimal('0')) + yf_value if not zk_by_source and not yf_by_source: return 0 updated_count = 0 # 更新 total_amount:用 F_Skd XS%.YfJinE 覆盖从 I_Sale.JinE 计算的值 for source_id, yf_total in yf_by_source.items(): updated = business_models.ExternalCustomerStatementOrder.objects.filter( merchant=merchant, customer=customer, category=business_models.ExternalCustomerStatementCategoryEnum.SALE, external_source_id=source_id, ).exclude(total_amount=yf_total).update(total_amount=yf_total) updated_count += updated # 更新 zk_amount for source_id, zk_total in zk_by_source.items(): updated = business_models.ExternalCustomerStatementOrder.objects.filter( merchant=merchant, customer=customer, category=business_models.ExternalCustomerStatementCategoryEnum.SALE, external_source_id=source_id, ).exclude(zk_amount=zk_total).update(zk_amount=zk_total) updated_count += updated if updated_count: _emit_progress( progress_callback, f'销售折扣/应付更新: {updated_count} 条记录已从 sale_discount 数据更新 (total_amount via YfJinE, zk_amount via ZkJinE)', ) return updated_count def _find_local_product_by_external_product_id( *, merchant: basic_models.Merchant, external_product_id: str, ): if not external_product_id: return None return basic_models.Product.objects.filter(merchant=merchant, human_id=external_product_id).first() def _parse_external_datetime(value: Any) -> datetime | None: text = str(value or '').strip() if not text: return None try: return datetime.fromisoformat(text.replace('Z', '+00:00')) except ValueError as exc: raise ExternalFinanceSyncError(f'外部日期时间格式非法: {text}') from exc def _decimal_to_string(value: Decimal) -> str: return f'{value:.2f}' def _emit_progress(progress_callback: Callable[[str], None] | None, message: str) -> None: if callable(progress_callback): progress_callback(message)