diff --git a/api_v1/views/business/README.md b/api_v1/views/business/README.md index 5acaea4..bd649ed 100644 --- a/api_v1/views/business/README.md +++ b/api_v1/views/business/README.md @@ -108,7 +108,7 @@ ### 4.1 列表 - **GET** `/api/v1/payment-orders/` -- 返回字段:`supplier_name`、`payment_date`、`amount`、`status` 等。 +- 返回字段:`supplier_name`、`payment_date`、`amount`、`status`、`is_external_source`、`external_source_id` 等。 ### 4.2 创建 - **POST** `/api/v1/payment-orders/` @@ -120,6 +120,11 @@ | `amount` | decimal | 付款金额(必须 > 0) | | `remarks` | str | 可选 | +列表/详情响应补充字段: + +- `is_external_source`:是否来源于外部系统同步。 +- `external_source_id`:外部系统记录 ID,用于关联与审计。 + ### 4.3 审批 / 作废 - **POST** `/api/v1/payment-orders//review/` - `{"action": "approve"}` 或 `{"action": "cancel"}` @@ -136,6 +141,11 @@ 审批通过表示“确认收款”,作废则恢复为初始状态。 +列表/详情响应同样包含: + +- `is_external_source` +- `external_source_id` + --- ## 6. 错误示例 diff --git a/api_v1/views/business/payment/views.py b/api_v1/views/business/payment/views.py index 5606730..00a70ba 100644 --- a/api_v1/views/business/payment/views.py +++ b/api_v1/views/business/payment/views.py @@ -19,7 +19,7 @@ class PaymentOrderSerializer(serializers.ModelSerializer): fields = [ 'id', 'supplier', 'supplier_name', 'bank_account', 'bank_account_name', 'payment_date', 'amount', 'discount_amount', 'settlement_amount', - 'operator', 'operator_name', 'status', + 'operator', 'operator_name', 'status', 'is_external_source', 'external_source_id', 'remarks', 'markup', 'created_at', 'updated_at', ] read_only_fields = [ @@ -29,6 +29,8 @@ class PaymentOrderSerializer(serializers.ModelSerializer): 'bank_account_name', 'settlement_amount', 'status', + 'is_external_source', + 'external_source_id', 'created_at', 'updated_at', ] diff --git a/api_v1/views/business/receipt/views.py b/api_v1/views/business/receipt/views.py index a713244..73ca4fe 100644 --- a/api_v1/views/business/receipt/views.py +++ b/api_v1/views/business/receipt/views.py @@ -19,7 +19,7 @@ class ReceiptOrderSerializer(serializers.ModelSerializer): fields = [ 'id', 'customer', 'customer_name', 'bank_account', 'bank_account_name', 'receipt_date', 'amount', 'discount_amount', 'settlement_amount', - 'operator', 'operator_name', 'status', + 'operator', 'operator_name', 'status', 'is_external_source', 'external_source_id', 'remarks', 'markup', 'created_at', 'updated_at', ] read_only_fields = [ @@ -29,6 +29,8 @@ class ReceiptOrderSerializer(serializers.ModelSerializer): 'bank_account_name', 'settlement_amount', 'status', + 'is_external_source', + 'external_source_id', 'created_at', 'updated_at', ] diff --git a/api_v1/views/business/statements/serializers.py b/api_v1/views/business/statements/serializers.py index 59fab22..930ccf0 100644 --- a/api_v1/views/business/statements/serializers.py +++ b/api_v1/views/business/statements/serializers.py @@ -31,6 +31,7 @@ class StatementRecordSerializer(serializers.Serializer): cumulative_amount = serializers.CharField() current_balance = serializers.CharField() arrears_amount = serializers.CharField() + remarks = serializers.CharField(allow_blank=True) items = serializers.ListField(child=serializers.DictField()) extra = serializers.DictField(required=False) diff --git a/business/admin.py b/business/admin.py index 8ad4066..04fe687 100644 --- a/business/admin.py +++ b/business/admin.py @@ -173,17 +173,23 @@ class SalesOrderItemAdmin(admin.ModelAdmin): @admin.register(models.PaymentOrder) class PaymentOrderAdmin(admin.ModelAdmin): - list_display = ('id', 'supplier', 'payment_date', 'amount', 'operator', 'status', 'created_at') - search_fields = ('supplier__name',) - list_filter = ('operator', 'status', 'created_at') + list_display = ( + 'id', 'supplier', 'payment_date', 'amount', 'operator', 'status', + 'is_external_source', 'external_source_id', 'created_at', + ) + search_fields = ('supplier__name', 'external_source_id') + list_filter = ('operator', 'status', 'is_external_source', 'created_at') ordering = ('-created_at',) @admin.register(models.ReceiptOrder) class ReceiptOrderAdmin(admin.ModelAdmin): - list_display = ('id', 'customer', 'receipt_date', 'amount', 'operator', 'status', 'created_at') - search_fields = ('customer__name',) - list_filter = ('operator', 'status', 'created_at') + list_display = ( + 'id', 'customer', 'receipt_date', 'amount', 'operator', 'status', + 'is_external_source', 'external_source_id', 'created_at', + ) + search_fields = ('customer__name', 'external_source_id') + list_filter = ('operator', 'status', 'is_external_source', 'created_at') ordering = ('-created_at',) diff --git a/business/external_finance_sync.py b/business/external_finance_sync.py new file mode 100644 index 0000000..97416dd --- /dev/null +++ b/business/external_finance_sync.py @@ -0,0 +1,841 @@ +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 + + +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 = {} + 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 = _to_decimal(record.get('FkJinE'), field_name='FkJinE') + discount_amount = _to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0')) + elif record_kind == 'refund': + amount = _to_decimal(record.get('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': 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 + matched_product = _find_local_product_by_external_product_id( + merchant=customer.merchant, + external_product_id=str(record.get('HpID') or '').strip(), + ) + product_name = getattr(matched_product, 'name', '') or str(record.get('HpID') or '').strip() + unit = str(record.get('JiJiaDW') or '').strip() or getattr(matched_product, 'get_unit_display', lambda: '')() + item_payload = { + 'product_id': getattr(matched_product, 'id', None), + 'product_name': product_name, + 'quantity': _decimal_to_string(quantity_value), + 'price': _decimal_to_string(price_value), + 'unit': unit, + 'color': '', + 'spec': getattr(matched_product, 'spec', '') or '', + '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': str(record.get('HpID') or '').strip(), + } + 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, + 更新到对应的 ExternalCustomerStatementOrder.zk_amount。 + 返回更新的记录数。 + """ + # 按 BianHaoID 聚合折扣(一个 BianHaoID 可能有多条 F_Skd 记录) + zk_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'))) + if zk_value: + zk_by_source[source_id] = zk_by_source.get(source_id, Decimal('0')) + zk_value + + if not zk_by_source: + return 0 + + updated_count = 0 + 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} 条记录的 zk_amount 已从 sale_discount 数据更新', + ) + 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) diff --git a/business/management/commands/sync_external_customer_finance.py b/business/management/commands/sync_external_customer_finance.py new file mode 100644 index 0000000..45f7d1a --- /dev/null +++ b/business/management/commands/sync_external_customer_finance.py @@ -0,0 +1,64 @@ +from django.core.management.base import BaseCommand, CommandError + +from basic_info import models as basic_models +from business.external_finance_sync import sync_customer_finance +from django.conf import settings + + +class Command(BaseCommand): + help = '按指定客户名从 HaoBuYe 外部财务接口同步收款/退款到 ReceiptOrder' + + def add_arguments(self, parser): + parser.add_argument('customer_name', type=str, help='外部客户名称(精确匹配)') + parser.add_argument('--operator-id', type=int, default=None, help='本地经办人 Employee.id') + parser.add_argument( + '--allow-create-customer', + action='store_true', + default=False, + help='当本地客户不存在时自动创建', + ) + parser.add_argument( + '--dry-run', + action='store_true', + default=False, + help='仅拉取和校验,不实际写入', + ) + parser.add_argument( + '--force-update', + action='store_true', + default=False, + help='当已有记录与外部数据不一致时,强制用外部数据覆盖本地记录', + ) + parser.add_argument( + '--progress-every', + type=int, + default=200, + help='长任务进度输出步长,默认每 200 条输出一次', + ) + + def handle(self, *args, **options): + customer_name = str(options['customer_name'] or '').strip() + operator_id = options.get('operator_id') or getattr(settings, 'HAOBUYE_FINANCE_SYNC_OPERATOR_ID', 0) + if not operator_id: + raise CommandError('必须提供 --operator-id,或配置 HAOBUYE_FINANCE_SYNC_OPERATOR_ID') + + try: + operator = basic_models.Employee.objects.select_related('merchant').get(id=operator_id) + except basic_models.Employee.DoesNotExist as exc: + raise CommandError(f'经办人不存在: {operator_id}') from exc + + try: + self.stdout.write(f'[sync] 开始处理客户: {customer_name}') + payload = sync_customer_finance( + customer_name=customer_name, + operator=operator, + allow_create_customer=options['allow_create_customer'], + dry_run=bool(options['dry_run']), + force_update=bool(options['force_update']), + progress_callback=lambda message: self.stdout.write(f'[sync] {message}'), + progress_every=max(1, int(options.get('progress_every') or 200)), + ) + except Exception as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write(self.style.SUCCESS(str(payload))) diff --git a/business/migrations/0028_payment_receipt_external_source_fields.py b/business/migrations/0028_payment_receipt_external_source_fields.py new file mode 100644 index 0000000..9d1a67d --- /dev/null +++ b/business/migrations/0028_payment_receipt_external_source_fields.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0027_purchaseorder_from_pre_purchase'), + ] + + operations = [ + migrations.AddField( + model_name='paymentorder', + name='external_source_id', + field=models.CharField( + blank=True, + db_index=True, + max_length=100, + null=True, + verbose_name='外部来源ID', + ), + ), + migrations.AddField( + model_name='paymentorder', + name='is_external_source', + field=models.BooleanField(default=False, verbose_name='是否外部来源'), + ), + migrations.AddField( + model_name='receiptorder', + name='external_source_id', + field=models.CharField( + blank=True, + db_index=True, + max_length=100, + null=True, + verbose_name='外部来源ID', + ), + ), + migrations.AddField( + model_name='receiptorder', + name='is_external_source', + field=models.BooleanField(default=False, verbose_name='是否外部来源'), + ), + ] \ No newline at end of file diff --git a/business/migrations/0029_externalcustomerstatementorder.py b/business/migrations/0029_externalcustomerstatementorder.py new file mode 100644 index 0000000..e9aca26 --- /dev/null +++ b/business/migrations/0029_externalcustomerstatementorder.py @@ -0,0 +1,42 @@ +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0026_transportvehicle_and_capacity'), + ('business', '0028_payment_receipt_external_source_fields'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalCustomerStatementOrder', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('category', models.CharField(choices=[('sale', '外部销售单'), ('sale_return', '外部销售退货单')], max_length=20, verbose_name='外部业务分类')), + ('external_customer_id', models.CharField(blank=True, default='', max_length=100, verbose_name='外部客户ID')), + ('external_source_id', models.CharField(db_index=True, max_length=100, verbose_name='外部单号')), + ('occurred_at', models.DateField(verbose_name='业务日期')), + ('recorded_at', models.DateTimeField(blank=True, null=True, verbose_name='录单时间')), + ('settlement_method', models.CharField(blank=True, default='', max_length=100, verbose_name='结算方式')), + ('total_amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=15, verbose_name='总金额')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('items_payload', models.JSONField(blank=True, default=list, verbose_name='明细快照')), + ('extra_payload', models.JSONField(blank=True, default=dict, verbose_name='扩展数据')), + ('customer', models.ForeignKey(on_delete=models.deletion.PROTECT, related_name='external_statement_orders', to='basic_info.customer', verbose_name='客户')), + ('merchant', models.ForeignKey(on_delete=models.deletion.PROTECT, related_name='external_customer_statement_orders', to='basic_info.merchant', verbose_name='所属商户')), + ], + options={ + 'verbose_name': '外部客户对账来源单', + 'verbose_name_plural': '外部客户对账来源单', + }, + ), + migrations.AddConstraint( + model_name='externalcustomerstatementorder', + constraint=models.UniqueConstraint(fields=('merchant', 'category', 'external_source_id'), name='uniq_external_customer_statement_order_source'), + ), + ] \ No newline at end of file diff --git a/business/migrations/0030_external_statement_add_sf_zk_amount.py b/business/migrations/0030_external_statement_add_sf_zk_amount.py new file mode 100644 index 0000000..f16e37d --- /dev/null +++ b/business/migrations/0030_external_statement_add_sf_zk_amount.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.8 on 2026-05-18 11:33 + +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0029_externalcustomerstatementorder'), + ] + + operations = [ + migrations.AddField( + model_name='externalcustomerstatementorder', + name='sf_amount', + field=models.DecimalField(decimal_places=2, default=Decimal('0'), help_text='销售单上的现场收款(SfJinE)合计,仅 sale 类型有值', max_digits=15, verbose_name='现场收款金额'), + ), + migrations.AddField( + model_name='externalcustomerstatementorder', + name='zk_amount', + field=models.DecimalField(decimal_places=2, default=Decimal('0'), help_text='该单据的折扣(ZkJinE)合计', max_digits=15, verbose_name='折扣金额'), + ), + ] diff --git a/business/models.py b/business/models.py index 617f6a2..dcbc9a0 100644 --- a/business/models.py +++ b/business/models.py @@ -981,6 +981,66 @@ class SalesReturnOrderItem(ModelBase): def total_amount(self): return round(self.price * self.real_quantity(), 2) + + +class ExternalCustomerStatementCategoryEnum(models.TextChoices): + SALE = 'sale', '外部销售单' + SALE_RETURN = 'sale_return', '外部销售退货单' + + +class ExternalCustomerStatementOrder(ModelBase): + """仅用于对账单计算的外部业务来源单据。""" + + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey( + basic_info_models.Merchant, + on_delete=models.PROTECT, + related_name='external_customer_statement_orders', + verbose_name='所属商户', + ) + customer = models.ForeignKey( + basic_info_models.Customer, + on_delete=models.PROTECT, + related_name='external_statement_orders', + verbose_name='客户', + ) + category = models.CharField( + max_length=20, + choices=ExternalCustomerStatementCategoryEnum.choices, + verbose_name='外部业务分类', + ) + external_customer_id = models.CharField(max_length=100, blank=True, default='', verbose_name='外部客户ID') + external_source_id = models.CharField(max_length=100, db_index=True, verbose_name='外部单号') + occurred_at = models.DateField(verbose_name='业务日期') + recorded_at = models.DateTimeField(blank=True, null=True, verbose_name='录单时间') + settlement_method = models.CharField(max_length=100, blank=True, default='', verbose_name='结算方式') + total_amount = models.DecimalField(max_digits=15, decimal_places=2, default=Decimal('0'), verbose_name='总金额') + sf_amount = models.DecimalField( + max_digits=15, decimal_places=2, default=Decimal('0'), + verbose_name='现场收款金额', + help_text='销售单上的现场收款(SfJinE)合计,仅 sale 类型有值', + ) + zk_amount = models.DecimalField( + max_digits=15, decimal_places=2, default=Decimal('0'), + verbose_name='折扣金额', + help_text='该单据的折扣(ZkJinE)合计', + ) + remarks = models.TextField(blank=True, null=True, verbose_name='备注') + items_payload = models.JSONField(default=list, blank=True, verbose_name='明细快照') + extra_payload = models.JSONField(default=dict, blank=True, verbose_name='扩展数据') + + class Meta: + verbose_name = '外部客户对账来源单' + verbose_name_plural = '外部客户对账来源单' + constraints = [ + models.UniqueConstraint( + fields=['merchant', 'category', 'external_source_id'], + name='uniq_external_customer_statement_order_source', + ) + ] + + def __str__(self): + return f'外部对账来源 {self.external_source_id} ({self.category})' def split_quantity_of_rolls(self) -> List[int]: if self.quantity_of_rolls: @@ -1036,6 +1096,17 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): default=PaymentOrderStatusEnum.PENDING, verbose_name='状态', ) + is_external_source = models.BooleanField( + default=False, + verbose_name='是否外部来源', + ) + external_source_id = models.CharField( + max_length=100, + null=True, + blank=True, + db_index=True, + verbose_name='外部来源ID', + ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: @@ -1108,6 +1179,17 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase): default=ReceiptOrderStatusEnum.PENDING, verbose_name='状态', ) + is_external_source = models.BooleanField( + default=False, + verbose_name='是否外部来源', + ) + external_source_id = models.CharField( + max_length=100, + null=True, + blank=True, + db_index=True, + verbose_name='外部来源ID', + ) remarks = models.TextField(blank=True, null=True, verbose_name='备注') class Meta: diff --git a/business/services.py b/business/services.py index 2c9d4b8..9656f86 100644 --- a/business/services.py +++ b/business/services.py @@ -1903,6 +1903,8 @@ STATEMENT_ORDER_TYPE_CHOICES = ( ('sales_order', '销售单'), ('sales_return_order', '销售退货单'), ('receipt_order', '收款单'), + ('external_sales_order', '外部销售单'), + ('external_sales_return_order', '外部销售退货单'), ('purchase_order', '采购单'), ('purchase_return_order', '采购退货单'), ('payment_order', '付款单'), @@ -1917,9 +1919,10 @@ def build_customer_statement( """ 根据客户历史单据生成对账记录,供多个 API 复用。 """ - balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer) - builder = _CustomerStatementBuilder(merchant=merchant, current_balance=balance) + local_balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer) + builder = _CustomerStatementBuilder(merchant=merchant, current_balance=local_balance) records = builder.collect_records(customer) + builder.adjust_current_balance(builder.external_balance_adjustment) return builder.build_payload( counterparty_id=customer.id, counterparty_name=customer.name, @@ -1975,6 +1978,10 @@ class _StatementBuilder: self._current_balance_value = _normalize_statement_amount(current_balance) self._current_balance_display = _decimal_to_string(self._current_balance_value) + def adjust_current_balance(self, delta: Decimal) -> None: + self._current_balance_value += _normalize_statement_amount(delta) + self._current_balance_display = _decimal_to_string(self._current_balance_value) + def collect_records(self, counterparty) -> List[dict]: # pragma: no cover - interface only raise NotImplementedError @@ -2008,6 +2015,7 @@ class _StatementBuilder: warehouse: basic_info_models.WareHouse | None = None, positive_amount, negative_amount, + remarks: str | None = '', items: List[dict] | None = None, extra: dict | None = None, ) -> dict: @@ -2026,6 +2034,7 @@ class _StatementBuilder: 'warehouse': warehouse, 'positive_amount': _normalize_statement_amount(positive_amount), 'negative_amount': _normalize_statement_amount(negative_amount), + 'remarks': remarks or '', 'items': items, } if extra: @@ -2107,11 +2116,16 @@ class _StatementBuilder: class _CustomerStatementBuilder(_StatementBuilder): + def __init__(self, *, merchant: basic_info_models.Merchant, current_balance: Decimal): + super().__init__(merchant=merchant, current_balance=current_balance) + self.external_balance_adjustment = Decimal('0') + def collect_records(self, customer: basic_info_models.Customer) -> List[dict]: records: List[dict] = [] records.extend(self._build_sales_records(customer)) records.extend(self._build_sales_return_records(customer)) records.extend(self._build_receipt_records(customer)) + records.extend(self._build_external_statement_records(customer)) return records def _build_sales_records(self, customer: basic_info_models.Customer) -> List[dict]: @@ -2141,6 +2155,7 @@ class _CustomerStatementBuilder(_StatementBuilder): warehouse=order.warehouse, positive_amount=order.get_total_amount(), negative_amount=_STATEMENT_ZERO, + remarks=order.remarks, items=items, ) ) @@ -2173,6 +2188,7 @@ class _CustomerStatementBuilder(_StatementBuilder): warehouse=order.warehouse, positive_amount=_STATEMENT_ZERO, negative_amount=order.get_total_amount(), + remarks=order.remarks, items=items, ) ) @@ -2189,6 +2205,9 @@ class _CustomerStatementBuilder(_StatementBuilder): ) records = [] for order in qs: + # ERP 口径:收款单的 ZkJinE 从应收侧扣减(负的 positive_amount), + # 已收只展示 FkJinE(order.amount)。净效果与 settlement_amount 一致。 + discount = order.discount_amount or Decimal('0') records.append( self._build_record( counterparty_id=order.customer_id, @@ -2200,8 +2219,55 @@ class _CustomerStatementBuilder(_StatementBuilder): recorded_at=order.created_at, status=order.status, status_label=order.get_status_display(), - positive_amount=_STATEMENT_ZERO, - negative_amount=order.get_total_amount(), + positive_amount=-discount, + negative_amount=order.amount, + remarks=order.remarks, + ) + ) + return records + + def _build_external_statement_records(self, customer: basic_info_models.Customer) -> List[dict]: + qs = models.ExternalCustomerStatementOrder.objects.filter( + merchant=self.merchant, + customer=customer, + ).order_by('occurred_at', 'recorded_at', 'id') + records = [] + for order in qs: + sf = order.sf_amount or Decimal('0') + zk = order.zk_amount or Decimal('0') + if order.category == models.ExternalCustomerStatementCategoryEnum.SALE: + source_type = 'external_sales_order' + source_label = '外部销售单' + # 净应收 = 毛额 - 折扣;现场收款作为 negative_amount 减少欠款 + positive_amount = order.total_amount - zk + negative_amount = sf + else: + source_type = 'external_sales_return_order' + source_label = '外部销售退货单' + # 退货减少应收:退货金额 + 退货折扣都减少欠款 + positive_amount = _STATEMENT_ZERO + negative_amount = order.total_amount + zk + self.external_balance_adjustment += positive_amount - negative_amount + records.append( + self._build_record( + counterparty_id=order.customer_id, + counterparty_name=order.customer.name, + source_type=source_type, + source_label=source_label, + source_id=order.id, + occurred_at=order.occurred_at, + recorded_at=order.recorded_at or order.created_at, + status=models.ReceiptOrderStatusEnum.APPROVED, + status_label='已同步', + positive_amount=positive_amount, + negative_amount=negative_amount, + remarks=order.remarks, + items=list(order.items_payload or []), + extra={ + 'external_source_id': order.external_source_id, + 'external_customer_id': order.external_customer_id, + 'settlement_method': order.settlement_method, + }, ) ) return records @@ -2242,6 +2308,7 @@ class _SupplierStatementBuilder(_StatementBuilder): warehouse=order.warehouse, positive_amount=order.get_total_amount(), negative_amount=_STATEMENT_ZERO, + remarks=order.remarks, items=items, ) ) @@ -2274,6 +2341,7 @@ class _SupplierStatementBuilder(_StatementBuilder): warehouse=order.warehouse, positive_amount=_STATEMENT_ZERO, negative_amount=order.get_total_amount(), + remarks=order.remarks, items=items, ) ) @@ -2303,6 +2371,7 @@ class _SupplierStatementBuilder(_StatementBuilder): status_label=order.get_status_display(), positive_amount=_STATEMENT_ZERO, negative_amount=order.get_total_amount(), + remarks=order.remarks, ) ) return records diff --git a/business/tests/test_external_finance_sync.py b/business/tests/test_external_finance_sync.py new file mode 100644 index 0000000..649ce7b --- /dev/null +++ b/business/tests/test_external_finance_sync.py @@ -0,0 +1,393 @@ +from decimal import Decimal +from io import StringIO +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase, override_settings + +from basic_info import models as basic_models +from business import models as business_models +from business.external_finance_sync import sync_customer_finance, sync_customer_finance_batch +from business.services import build_customer_statement, build_supplier_statement + +from .fixtures import create_basic_fixtures + + +class FakeHaoBuYeClient: + def __init__(self, *, finance_payloads=None, customers_payload=None, i_sale_customer_payloads=None): + self.finance_payloads = finance_payloads or {} + self.customers_payload = customers_payload or { + 'customers': [], + 'cursor_id': None, + 'next_cursor_id': None, + } + self.i_sale_customer_payloads = i_sale_customer_payloads or {} + + def fetch_customer_finance(self, *, customer_name, include_adjustments=True, include_cash_movement=True): + return self.finance_payloads[customer_name] + + def list_customers(self, *, cursor_id=None): + return self.customers_payload + + def fetch_i_sale_by_customer(self, *, customer_id, category): + return self.i_sale_customer_payloads.get( + (customer_id, category), + { + 'status': 'not_found', + 'records': [], + }, + ) + + +class ExternalFinanceSyncTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + + def test_sync_customer_finance_creates_receipt_and_refund_orders(self): + client = FakeHaoBuYeClient( + finance_payloads={ + '张晓鹏': { + 'status': 'active', + 'customer_name': '张晓鹏', + 'customer_ids': ['KH00308'], + 'receipts': [ + { + 'BianHaoID': 'SK20225267', + 'KhID': 'KH00308', + 'RiQi': '2026-05-07T00:00:00Z', + 'FkJinE': '198708.00', + 'ZkJinE': '12.00', + 'JieSunFS': '月结', + 'ZhaiYao': '正常收款', + 'BeiZhu': '回款备注', + }, + { + 'BianHaoID': 'SK20208849', + 'KhID': 'KH00308', + 'RiQi': '2022-05-31T00:00:00Z', + 'FkJinE': '0.00', + 'ZkJinE': '151.00', + 'JieSunFS': '月结', + 'ZhaiYao': '纯折扣收款', + 'BeiZhu': '', + }, + ], + 'refunds': [ + { + 'BianHaoID': 'XT20203479', + 'KhID': 'KH00308', + 'RiQi': '2025-11-25T00:00:00Z', + 'YfJinE': '-490.00', + 'FkJinE': '0.00', + 'JieSunFS': '欠款', + 'ZhaiYao': '成品销售退货单XT20203479[欠款]', + 'BeiZhu': '', + } + ], + } + }, + i_sale_customer_payloads={ + ( + 'KH00308', + business_models.ExternalCustomerStatementCategoryEnum.SALE, + ): { + 'status': 'active', + 'records': [ + { + 'SubID': 1, + 'BianHaoID': 'XS20367431', + 'DanType': '成品销售单', + 'KhID': 'KH00308', + 'RiQi': '2026-05-14T00:00:00Z', + 'KdRiQi': '2026-05-14T19:08:23Z', + 'JieSunFS': '欠款', + 'HpID': 'HP00001', + 'JiJiaDW': '米', + 'JianShu': '1.00', + 'ShuLiang': '20.00', + 'DanJia': '32.000000', + 'JinE': '640.000000', + 'BeiZhu': '外部销售备注', + 'BeiZhuC': '', + 'BeiZhuD': '', + 'MeoD': '普通单据', + } + ], + }, + ( + 'KH00308', + business_models.ExternalCustomerStatementCategoryEnum.SALE_RETURN, + ): { + 'status': 'active', + 'records': [ + { + 'SubID': 2, + 'BianHaoID': 'XT20203479', + 'DanType': '客户退货单', + 'KhID': 'KH00308', + 'RiQi': '2025-11-25T00:00:00Z', + 'KdRiQi': '2025-11-25T18:08:23Z', + 'JieSunFS': '欠款', + 'HpID': 'HP00001', + 'JiJiaDW': '米', + 'JianShu': '1.00', + 'ShuLiang': '5.00', + 'DanJia': '98.000000', + 'JinE': '490.000000', + 'BeiZhu': '', + 'BeiZhuC': '', + 'BeiZhuD': '', + 'MeoD': '退货单据', + } + ], + }, + }, + ) + + basic_models.Product.objects.create( + merchant=self.merchant, + category=basic_models.ProductCategory.objects.get(merchant=self.merchant), + name='外部面料', + human_id='HP00001', + unit=basic_models.ProductUnitEnum.METER, + ) + + payload = sync_customer_finance( + customer_name='张晓鹏', + operator=self.operator, + client=client, + allow_create_customer=True, + ) + + self.assertEqual(payload['created_count'], 3) + self.assertEqual(payload['skipped_existing_count'], 0) + self.assertEqual(payload['skipped_zero_settlement_count'], 0) + self.assertEqual(payload['external_business_created_count'], 2) + self.assertEqual(payload['sales_seen'], 1) + self.assertEqual(payload['sale_returns_seen'], 1) + + customer = basic_models.Customer.objects.get(merchant=self.merchant, name='张晓鹏') + orders = list( + business_models.ReceiptOrder.objects.filter(merchant=self.merchant, customer=customer).order_by('external_source_id') + ) + self.assertEqual(len(orders), 3) + by_external_id = {order.external_source_id: order for order in orders} + + self.assertEqual(by_external_id['SK20225267'].amount, Decimal('198708.00')) + self.assertEqual(by_external_id['SK20225267'].discount_amount, Decimal('12.00')) + self.assertEqual(by_external_id['SK20225267'].status, business_models.ReceiptOrderStatusEnum.APPROVED) + + self.assertEqual(by_external_id['SK20208849'].amount, Decimal('0.00')) + self.assertEqual(by_external_id['SK20208849'].discount_amount, Decimal('151.00')) + self.assertEqual(by_external_id['SK20208849'].settlement_amount, Decimal('151.00')) + + self.assertEqual(by_external_id['XT20203479'].amount, Decimal('-490.00')) + self.assertEqual(by_external_id['XT20203479'].discount_amount, Decimal('0.00')) + self.assertEqual(by_external_id['XT20203479'].status, business_models.ReceiptOrderStatusEnum.APPROVED) + + balance = business_models.CustomerBalance.objects.get(merchant=self.merchant, customer=customer) + self.assertEqual(balance.balance, Decimal('-198381.00')) + + external_orders = list( + business_models.ExternalCustomerStatementOrder.objects.filter( + merchant=self.merchant, + customer=customer, + ).order_by('category', 'external_source_id') + ) + self.assertEqual(len(external_orders), 2) + self.assertEqual(external_orders[0].total_amount, Decimal('640.00')) + self.assertEqual(external_orders[1].total_amount, Decimal('490.00')) + + statement_payload = build_customer_statement(merchant=self.merchant, customer=customer) + records_by_type = {record['source_type']: record for record in statement_payload['records']} + source_types = list(records_by_type.keys()) + self.assertIn('external_sales_order', source_types) + self.assertIn('external_sales_return_order', source_types) + self.assertEqual(records_by_type['receipt_order']['remarks'], '正常收款\n回款备注') + self.assertEqual(records_by_type['external_sales_order']['remarks'], 'BeiZhu: 外部销售备注\nMeoD: 普通单据') + self.assertEqual(records_by_type['external_sales_return_order']['remarks'], 'MeoD: 退货单据') + self.assertEqual(statement_payload['records'][0]['current_balance'], '-198231.00') + + def test_build_supplier_statement_records_always_include_remarks(self): + purchase_order = business_models.PurchaseOrder.objects.create( + merchant=self.merchant, + supplier=self.supplier, + purchase_date='2026-05-01', + warehouse=self.warehouse_relaxed, + operator=self.operator, + status=business_models.PurchaseOrderStatusEnum.APPROVED, + remarks='采购备注', + ) + purchase_return_order = business_models.PurchaseReturnOrder.objects.create( + merchant=self.merchant, + supplier=self.supplier, + return_date='2026-05-02', + warehouse=self.warehouse_relaxed, + purchase_order=purchase_order, + status=business_models.PurchaseReturnStatusEnum.APPROVED, + remarks='退货备注', + ) + business_models.PaymentOrder.objects.create( + merchant=self.merchant, + supplier=self.supplier, + payment_date='2026-05-03', + amount=Decimal('100.00'), + discount_amount=Decimal('0.00'), + operator=self.operator, + status=business_models.PaymentOrderStatusEnum.APPROVED, + remarks='付款备注', + ) + + statement_payload = build_supplier_statement(merchant=self.merchant, supplier=self.supplier) + + records_by_type = {record['source_type']: record for record in statement_payload['records']} + self.assertEqual(records_by_type['purchase_order']['remarks'], '采购备注') + self.assertEqual(records_by_type['purchase_return_order']['remarks'], '退货备注') + self.assertEqual(records_by_type['payment_order']['remarks'], '付款备注') + self.assertTrue(all('remarks' in record for record in statement_payload['records'])) + + def test_sync_customer_finance_is_idempotent_for_same_external_source_id(self): + customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='宏洋', + created_by=self.operator, + ) + business_models.ReceiptOrder.objects.create( + merchant=self.merchant, + customer=customer, + receipt_date='2026-05-09', + amount=Decimal('37839.00'), + discount_amount=Decimal('0.00'), + operator=self.operator, + status=business_models.ReceiptOrderStatusEnum.APPROVED, + is_external_source=True, + external_source_id='SK20225323', + remarks='existing', + ) + + client = FakeHaoBuYeClient( + finance_payloads={ + '宏洋': { + 'status': 'active', + 'customer_name': '宏洋', + 'customer_ids': ['KH01474'], + 'receipts': [ + { + 'BianHaoID': 'SK20225323', + 'KhID': 'KH01474', + 'RiQi': '2026-05-09T00:00:00Z', + 'FkJinE': '37839.00', + 'ZkJinE': '0.00', + 'JieSunFS': '月结', + 'ZhaiYao': '', + 'BeiZhu': '', + } + ], + 'refunds': [], + } + } + ) + + payload = sync_customer_finance( + customer_name='宏洋', + operator=self.operator, + client=client, + allow_create_customer=False, + ) + + self.assertEqual(payload['created_count'], 0) + self.assertEqual(payload['skipped_existing_count'], 1) + self.assertEqual(payload['external_business_created_count'], 0) + self.assertEqual( + business_models.ReceiptOrder.objects.filter(merchant=self.merchant, external_source_id='SK20225323').count(), + 1, + ) + + def test_sync_customer_finance_batch_reuses_single_customer_sync_flow(self): + client = FakeHaoBuYeClient( + customers_payload={ + 'cursor_id': 'KH00001', + 'next_cursor_id': 'KH00011', + 'customers': [ + {'customer_id': 'KH00002', 'customer_name': '客户甲'}, + {'customer_id': 'KH00003', 'customer_name': '客户乙'}, + ], + }, + finance_payloads={ + '客户甲': { + 'status': 'active', + 'customer_name': '客户甲', + 'customer_ids': ['KH00002'], + 'receipts': [], + 'refunds': [], + }, + '客户乙': { + 'status': 'active', + 'customer_name': '客户乙', + 'customer_ids': ['KH00003'], + 'receipts': [ + { + 'BianHaoID': 'SK-TEST-1', + 'KhID': 'KH00003', + 'RiQi': '2026-05-01T00:00:00Z', + 'FkJinE': '100.00', + 'ZkJinE': '0.00', + 'JieSunFS': '现金', + 'ZhaiYao': '', + 'BeiZhu': '', + } + ], + 'refunds': [], + }, + }, + ) + + payload = sync_customer_finance_batch( + operator=self.operator, + cursor_id='KH00001', + client=client, + allow_create_customer=True, + ) + + self.assertEqual(payload['count'], 2) + self.assertEqual(payload['next_cursor_id'], 'KH00011') + self.assertEqual(payload['results'][0]['customer_name'], '客户甲') + self.assertEqual(payload['results'][1]['created_count'], 1) + + +class ExternalFinanceSyncCommandTestCase(TestCase): + def setUp(self): + ( + self.merchant, + self.supplier, + self.warehouse_strict, + self.warehouse_relaxed, + self.product, + self.operator, + ) = create_basic_fixtures() + + @override_settings(HAOBUYE_FINANCE_SYNC_OPERATOR_ID=0) + def test_command_sync_external_customer_finance(self): + stdout = StringIO() + with patch( + 'business.management.commands.sync_external_customer_finance.sync_customer_finance', + return_value={'customer_name': '宏洋', 'created_count': 2}, + ) as mock_sync: + call_command( + 'sync_external_customer_finance', + '宏洋', + '--operator-id', + str(self.operator.id), + '--allow-create-customer', + stdout=stdout, + ) + + mock_sync.assert_called_once() + self.assertIn('created_count', stdout.getvalue()) diff --git a/business/tests/test_payment_receipt.py b/business/tests/test_payment_receipt.py index 1afa500..ede7e39 100644 --- a/business/tests/test_payment_receipt.py +++ b/business/tests/test_payment_receipt.py @@ -5,6 +5,8 @@ from django.utils import timezone from basic_info import models as basic_models from business import models as business_models, services +from api_v1.views.business.payment.views import PaymentOrderSerializer +from api_v1.views.business.receipt.views import ReceiptOrderSerializer from .fixtures import create_basic_fixtures @@ -247,3 +249,43 @@ class PaymentReceiptServiceTestCase(TestCase): ) self.assertEqual(records.count(), 1) self.assertEqual(records.first().balance_after, balance.balance) + + def test_payment_order_external_source_fields_default_and_serialized(self): + order = services.create_payment_order( + merchant=self.merchant, + supplier=self.supplier, + payment_date=timezone.now().date(), + amount='88.00', + operator=self.operator, + ) + self.assertFalse(order.is_external_source) + self.assertIsNone(order.external_source_id) + + order.is_external_source = True + order.external_source_id = 'XT20260001' + order.save(update_fields=['is_external_source', 'external_source_id', 'updated_at']) + order.refresh_from_db() + + serializer = PaymentOrderSerializer(order) + self.assertTrue(serializer.data['is_external_source']) + self.assertEqual(serializer.data['external_source_id'], 'XT20260001') + + def test_receipt_order_external_source_fields_default_and_serialized(self): + order = services.create_receipt_order( + merchant=self.merchant, + customer=self.customer, + receipt_date=timezone.now().date(), + amount='66.00', + operator=self.operator, + ) + self.assertFalse(order.is_external_source) + self.assertIsNone(order.external_source_id) + + order.is_external_source = True + order.external_source_id = 'SK20225323' + order.save(update_fields=['is_external_source', 'external_source_id', 'updated_at']) + order.refresh_from_db() + + serializer = ReceiptOrderSerializer(order) + self.assertTrue(serializer.data['is_external_source']) + self.assertEqual(serializer.data['external_source_id'], 'SK20225323') diff --git a/docs/CUSTOMER_DEBT_CALCULATION(1).md b/docs/CUSTOMER_DEBT_CALCULATION(1).md new file mode 100644 index 0000000..85c3a59 --- /dev/null +++ b/docs/CUSTOMER_DEBT_CALCULATION(1).md @@ -0,0 +1,221 @@ +# 客户欠款统计口径 + +本文档定义客户欠款的计算方式,已通过 4 个客户的 ERP 导出数据交叉验证,全部精确对上。 + +--- + +## 1. 核心公式 + +```text +欠款 = AR - 收款 - 现场收款 - 折扣 + = (sales.JinE + sale_returns.JinE) + - receipt.FkJinE + - sales.SfJinE(按 BianHaoID 去重) + - (sales.ZkJinE + sale_returns.ZkJinE + receipt.ZkJinE) +``` + +各项说明: + +| 项目 | 数据来源 | 筛选条件 | 取值字段 | 去重规则 | +|------|----------|----------|----------|----------| +| 销售额 | I_Sale | DanType='成品销售单' AND BianHaoID LIKE 'XS%' | SUM(JinE) | 不去重,每行明细独立 | +| 退货抵扣 | I_Sale | DanType='客户退货单' AND BianHaoID LIKE 'XT%' | SUM(JinE) | 不去重,JinE 本身为负数 | +| 收款 | F_Skd | BianHaoID LIKE 'SK%' | SUM(FkJinE) | 不去重,每行独立 | +| 现场收款 | I_Sale | DanType='成品销售单' AND SfJinE != 0 | SUM(SfJinE) | **必须按 BianHaoID 去重** | +| 折扣 | I_Sale + F_Skd | 所有相关行 | SUM(ZkJinE) | 不去重 | + +--- + +## 2. SfJinE 去重规则(关键) + +`SfJinE` 是**单据头级别字段**,被冗余写到同一 BianHaoID 的每一行明细上。 + +示例(张晓鹏 XS20205788,5 行明细): + +```text +SubID=1 JinE=380 SfJinE=22,287 ← 同一个值 +SubID=2 JinE=11,668 SfJinE=22,287 ← 重复 +SubID=3 JinE=3,864 SfJinE=22,287 ← 重复 +SubID=4 JinE=3,206 SfJinE=22,287 ← 重复 +SubID=5 JinE=3,169 SfJinE=22,287 ← 重复 +``` + +正确做法:每个 BianHaoID 只取一次 SfJinE = 22,287。 +错误做法:直接 SUM 所有行 = 22,287 × 5 = 111,435(多算 4 倍)。 + +--- + +## 3. 不参与计算的字段 + +| 字段 | 所在表 | 原因 | +|------|--------|------| +| I_Sale.YfJinE | I_Sale | 全部为 0 | +| I_Sale.DingJin | I_Sale | 订金标记,实际入账已通过 SK/SfJinE 体现 | +| I_Sale.JinET | I_Sale | 信息字段,不参与欠款 | +| I_Sale.LjQK | I_Sale | 滚动累欠快照,不是增量 | +| I_Sale.DanJiaCB / JinECB / JinE_SL | I_Sale | 全部为 0 | +| F_Skd.YfJinE | F_Skd | XS 行与 I_Sale.JinE 重复;XT 行与退货重复 | +| F_Skd.LjJinE | F_Skd | 累计金额,不参与求和 | +| F_Skd.DjJinE / JyJinE | F_Skd | 订金/结余,不参与 | +| refund 桶 | F_Skd | 与 sale_returns.JinE 等值,不重复计入 | + +--- + +## 4. 对应 API 接口 + +| 桶 | 接口 | 参数 | +|----|------|------| +| 销售单 | GET /api/v1/i-sale/by-customer | customer_id=KHxxxxx&category=sale | +| 销售退货 | GET /api/v1/i-sale/by-customer | customer_id=KHxxxxx&category=sale_return | +| 收款单 | GET /api/v1/finance/by-customer | customer_name_b64=xxx&record_types=receipt | + +所有接口无分页限制,一次返回全集。 + +Base URL: `http://43.139.183.222:18080` +Auth: `Authorization: your-fixed-authorization-secret` + +--- + +## 5. ERP 对账单滚动累计逻辑 + +ERP 对账单按时间排序,每行的"结欠金额"是滚动累计: + +```text +结欠[n] = 结欠[n-1] + 本行应收金额 - 本行已收金额 +``` + +- XS 销售单:应收 = JinE,已收 = SfJinE(现场收款,多数为 0) +- SK 收款单:应收 = 0(或负数折扣),已收 = FkJinE +- XT 退货单:应收 = JinE(负数),已收 = 0 + +--- + +## 6. 折扣与 ERP "应收金额"的关系 + +ERP 展示的"应收金额"是净应收(JinE - ZkJinE),我们从 I_Sale.JinE 拿到的是毛额。 + +```text +ERP 视角: 欠款 = Σ(应收_净) - Σ(已收) +API 视角: 欠款 = Σ(JinE) - Σ(FkJinE) - Σ(SfJinE去重) - Σ(ZkJinE) +``` + +两者数学等价。 + +--- + +## 7. 验证结果(全部精确对上) + +| 客户 | KhID | API 欠款 | ERP 欠款 | 差额 | +|------|------|---:|---:|---:| +| 木棉 | KH01078 | 10,718 | 10,718 | 0 | +| 腾飞纺织 | KH00311 | 53,521 | 53,521 | 0 | +| 金庸 | KH00290 | 82,670 | 82,670 | 0 | +| 张晓鹏 | KH00308 | 753,897 | 753,897 | 0 | + +各客户 SfJinE 情况: + +| 客户 | SfJinE(去重后) | 笔数 | 说明 | +|------|---:|---:|------| +| 木棉 | 0 | 0 | 无现场收款 | +| 腾飞纺织 | 0 | 0 | 无现场收款 | +| 金庸 | 23,394 | 1 笔 | XS20214770 微信收款 | +| 张晓鹏 | 77,132 | 7 笔 | 2020-09 期间支付宝/微信收款 | + +--- + +## 8. 伪代码 + +```python +def calculate_customer_debt(customer_id: str, customer_name_b64: str) -> float: + # 1. 拉销售单 + sales = api.get("/api/v1/i-sale/by-customer", + customer_id=customer_id, category="sale") + + # 2. 拉销售退货 + sale_returns = api.get("/api/v1/i-sale/by-customer", + customer_id=customer_id, category="sale_return") + + # 3. 拉收款单 + receipts = api.get("/api/v1/finance/by-customer", + customer_name_b64=customer_name_b64, record_types="receipt") + + # 4. 计算 AR(每行明细的 JinE 独立求和) + ar = sum(r.JinE for r in sales.records) + sum(r.JinE for r in sale_returns.records) + + # 5. 收款 + received_sk = sum(r.FkJinE for r in receipts.receipts) + + # 6. 现场收款(按 BianHaoID 去重,每单只取一次) + seen = set() + received_inline = 0.0 + for r in sales.records: + if r.BianHaoID not in seen: + seen.add(r.BianHaoID) + received_inline += r.SfJinE + + # 7. 折扣 + discount = (sum(r.ZkJinE for r in sales.records) + + sum(r.ZkJinE for r in sale_returns.records) + + sum(r.ZkJinE for r in receipts.receipts)) + + # 8. 欠款 + return ar - received_sk - received_inline - discount +``` + +--- + +## 9. 按时间窗口计算 + +```python +def calculate_debt_as_of(customer_id, customer_name_b64, cutoff_date): + # 拉取同上,然后按 RiQi < cutoff_date 过滤 + sales_f = [r for r in sales if r.RiQi < cutoff_date] + sr_f = [r for r in sale_returns if r.RiQi < cutoff_date] + rcp_f = [r for r in receipts if r.RiQi < cutoff_date] + + ar = sum(r.JinE for r in sales_f) + sum(r.JinE for r in sr_f) + received_sk = sum(r.FkJinE for r in rcp_f) + + seen = set() + received_inline = 0.0 + for r in sales_f: + if r.BianHaoID not in seen: + seen.add(r.BianHaoID) + received_inline += r.SfJinE + + discount = (sum(r.ZkJinE for r in sales_f) + + sum(r.ZkJinE for r in sr_f) + + sum(r.ZkJinE for r in rcp_f)) + + return ar - received_sk - received_inline - discount +``` + +--- + +## 10. antd-demo 后端待修复的 Bug + +### Bug 1:SfJinE 未去重 + +当前同步代码对 I_Sale 明细行直接求和 SfJinE,导致多行明细的订单被重复计入。 + +修复:按 BianHaoID 分组,每组只取一次 SfJinE。 + +### Bug 2:退货单金额放错列 + +退货金额被放到"已收金额"(negative_amount),应该放到"本单应收"(positive_amount=0, negative_amount=|JinE|,使得应收为负数)。 + +### Bug 3:退货单被冗余写入收款桶 + +9 条 XT 退货单同时出现在"外部销售退货单"和"收款单"两种类型里,导致收款行数多了 9 条。 + +修复:同步时 XT 退货只写入退货桶,不写入收款桶。 + +--- + +## 变更记录 + +| 日期 | 内容 | +|------|------| +| 2026-05-18 | 初版,木棉/腾飞纺织验证通过 | +| 2026-05-18 | 金庸验证发现 SfJinE 必须参与扣减 | +| 2026-05-19 | **最终版**:张晓鹏验证发现 SfJinE 必须按 BianHaoID 去重。四个客户全部精确对上 ERP | diff --git a/docs/FINANCE-API-AGENT-HANDOFF.md b/docs/FINANCE-API-AGENT-HANDOFF.md new file mode 100644 index 0000000..1f58fe2 --- /dev/null +++ b/docs/FINANCE-API-AGENT-HANDOFF.md @@ -0,0 +1,257 @@ +# Customer Finance API Handoff For AI Agent + +本文档面向会中协作的 AI agent,用于通过当前 API 配合财务人员核实指定客户的销售、销退、收款、退款数据。 + +## Scope + +当前只使用这一个接口: + +- `GET /api/v1/finance/by-customer` + +当前业务口径已经固定: + +- 销售:只认 `dbo.I_Sale` 中 `DanType = 成品销售单` +- 销退:只认 `dbo.I_Sale` 中 `DanType = 客户退货单` +- 收款:只认 `dbo.F_Skd` 中 `BianHaoID LIKE 'XS%'` +- 退款:只认 `dbo.F_Skd` 中 `BianHaoID LIKE 'XT%'` + +## Access + +Base URL: + +```text +http://43.139.183.222:18080 +``` + +Authorization header format: + +```http +Authorization: +``` + +说明: + +- 本仓库不写入真实鉴权密钥。 +- 实际值请从目标环境部署配置中的 `auth.secret` 获取。 +- 当前系统仍然使用固定值鉴权,不是动态 token。 + +当前线上固定鉴权值: + +- `Authorization: your-fixed-authorization-secret` + +## Purpose + +该接口用于: + +1. 按客户名称精确抽取该客户的指定财务记录。 +2. 在会议中快速切换不同记录类型,逐项与财务人员核对。 +3. 根据需要区分“真实资金变动”和“挂账/欠款调整”。 + +## Request Shape + +```http +GET /api/v1/finance/by-customer?customer_name_b64=...&record_types=...&include_cash_movement=...&include_adjustments=... +``` + +参数: + +- `customer_name_b64`: 必填,客户名称的 Base64URL 编码。 +- `record_types`: 可选,逗号分隔,支持 `sale`、`sale_return`、`receipt`、`refund`。 +- `include_cash_movement`: 可选,布尔值,默认 `true`。 +- `include_adjustments`: 可选,布尔值,默认 `false`。 + +## Current Semantics + +### 0. 用户可以查询哪些财务数据 + +当前支持查询的记录类型只有 4 类: + +- `sale`:销售记录 +- `sale_return`:销退 / 客户退货记录 +- `receipt`:收款 / 回款记录 +- `refund`:退款记录 + +说明: + +- 可以单选一种类型,例如只查 `sale_return` +- 也可以多选多种类型,例如 `sale,receipt` +- 如果用户问的是“这个客户有哪些财务记录”这类宽泛问题,可以查询四类全部数据 +- 但会中更建议显式指定类型,避免一次返回过多记录 + +### 1. `record_types` + +可选值: + +- `sale` +- `sale_return` +- `receipt` +- `refund` + +示例: + +- 只查销售:`record_types=sale` +- 只查销退:`record_types=sale_return` +- 只查收款:`record_types=receipt` +- 只查退款:`record_types=refund` +- 同时查销售和收款:`record_types=sale,receipt` + +注意: + +- 如果不传 `record_types`,当前实现会默认查询四类全部数据。 +- 因为返回的是“当前条件命中的全部记录”,会中使用时建议始终显式传 `record_types`,避免一次取太多数据。 + +### 2. `include_cash_movement` + +控制是否包含真实资金变动记录。 + +对 `receipt`: + +- `true` 时取 `FkJinE > 0` + +对 `refund`: + +- `true` 时取 `FkJinE < 0` + +### 3. `include_adjustments` + +控制是否包含挂账/欠款调整记录。 + +对 `receipt` 和 `refund`: + +- `true` 时额外包含 `FkJinE = 0` + +典型含义: + +- `FkJinE > 0`:客户真实收款 +- `FkJinE < 0`:客户真实退款 +- `FkJinE = 0`:挂账、欠款、非现金调整 + +## Recommended Meeting Flow + +建议会中按以下顺序核实: + +1. 先查销售:确认该客户近期销售单是否齐全。 +2. 再查销退:确认是否存在退货单以及备注原因。 +3. 再查收款:先只看真实资金流入。 +4. 再查退款:先只看真实资金流出。 +5. 如果财务提到“这笔不是付款,是挂账冲减”,再打开 `include_adjustments=true` 复核。 + +## Ready-To-Use Examples + +### Only Sales + +```http +GET /api/v1/finance/by-customer?customer_name_b64=&record_types=sale +Authorization: +``` + +### Only Sale Returns + +```http +GET /api/v1/finance/by-customer?customer_name_b64=&record_types=sale_return +Authorization: +``` + +### Only Receipts With Real Cash Movement + +```http +GET /api/v1/finance/by-customer?customer_name_b64=&record_types=receipt&include_cash_movement=true&include_adjustments=false +Authorization: +``` + +### Only Refunds With Real Cash Movement + +```http +GET /api/v1/finance/by-customer?customer_name_b64=&record_types=refund&include_cash_movement=true&include_adjustments=false +Authorization: +``` + +### Only Refund Adjustments + +```http +GET /api/v1/finance/by-customer?customer_name_b64=&record_types=refund&include_cash_movement=false&include_adjustments=true +Authorization: +``` + +### Sales And Receipts Together + +```http +GET /api/v1/finance/by-customer?customer_name_b64=&record_types=sale,receipt&include_cash_movement=true&include_adjustments=false +Authorization: +``` + +## Response Reading Guide + +关键字段: + +- `customer_name` +- `customer_ids` +- `record_types` +- `include_cash_movement` +- `include_adjustments` +- `total_count` +- `sales_count` +- `sale_returns_count` +- `receipts_count` +- `refunds_count` +- `sales` +- `sale_returns` +- `receipts` +- `refunds` + +注意: + +- 返回结构按类别拆分,不是一个混合数组。 +- 只请求某一类时,其它类别通常为 `null` 或计数为 `0`。 +- 当前不会做按单聚合,返回的是原始命中记录。 + +## Known Limitations + +当前接口只支持以下过滤维度: + +1. 客户名称 +2. 财务记录类型 +3. 是否包含真实资金变动 +4. 是否包含挂账/欠款调整 + +当前不支持: + +1. 时间范围过滤 +2. 分页 +3. 排序参数 +4. 数量限制 +5. 写入、修改、删除、审批、纠正财务数据 +6. 非简单累计之外的统计分析 +7. 按季度、按月、按年等时间维度汇总 +8. 环比、同比、趋势分析、占比分析、分组统计 +9. 筛选后的分组累计、小计、分类汇总 + +因此,当前返回的是“指定客户 + 指定类型 + 指定资金口径”下的全部命中数据。 + +这里的“允许的简单累计”仅指: + +- 对当前 API 已返回的同类记录做直接求和或直接计数 + +不允许的情况包括: + +- 先按额外条件切片后再做累计 +- 先按季度/月度分桶后再汇总 +- 任何需要派生统计口径的复杂计算 + +## What To Tell Finance In The Meeting + +可以直接这样解释: + +1. 现在可以按客户名只查销售,或只查销退,或只查收款,或只查退款。 +2. 收款和退款还可以区分成“真实资金变动”和“挂账调整”。 +3. 但当前还不能按时间截取,也不能分页,所以结果是当前条件下的全量命中集。 + +## Practical Advice For The Agent + +1. 会中尽量显式传 `record_types`,不要依赖默认全量。 +2. 查收款/退款时,先用 `include_cash_movement=true&include_adjustments=false`,先看真实收付款。 +3. 只有在财务明确提到“挂账”“冲减”“欠款”时,再补查 `include_adjustments=true`。 +4. 如果返回 `not_found`,先不要直接下结论,优先确认客户名称是否与主数据 `B_Khzl.KhName` 完全一致。 +5. 如果用户问“可以查哪些财务数据”,应明确回答当前支持:销售、销退、收款、退款,并说明可以单选也可以多选。 +6. 如果用户要求写入财务数据,必须拒绝,并说明当前 API 只支持查询。 +7. 如果用户要求复杂统计,必须拒绝,并说明当前只支持原始命中记录查询,以及必要时基于返回结果做简单累计或计数。 \ No newline at end of file diff --git a/docs/SALE-DISCOUNT-API-UPDATE.md b/docs/SALE-DISCOUNT-API-UPDATE.md new file mode 100644 index 0000000..7936120 --- /dev/null +++ b/docs/SALE-DISCOUNT-API-UPDATE.md @@ -0,0 +1,117 @@ +# 销售折扣数据(ZkJinE)接口更新说明 + +## 问题回顾 + +你们反馈 `/api/v1/i-sale/by-customer` 接口的 `ZkJinE` 字段始终返回 0,导致销售折扣金额(583 元部分)无法正确获取。 + +## 根因确认 + +经查证,问题属实但需要澄清: + +- `/api/v1/i-sale/by-customer` 接口**确实返回了 `ZkJinE` 字段**,但该字段的数据来源是 `I_Sale` 表,而 `I_Sale` 表中该字段的值本身就是 0。这不是接口遗漏字段,而是数据源的问题。 +- **真实的销售折扣数据**存在于 `F_Skd` 表中 `BianHaoID` 以 `XS` 开头的记录里。 + +## 解决方案 + +我们已在 `/api/v1/finance/by-customer` 接口新增了 `sale_discount` 类型,用于从 `F_Skd` 表查询 XS% 行的财务数据(包含 `ZkJinE`)。 + +**此改动为纯增量更新,不影响任何现有接口和字段。** + +--- + +## 对接方式 + +### 请求 + +``` +GET /api/v1/finance/by-customer?customer_name_b64={base64url编码的客户名}&record_types=sale_discount +``` + +也可以和其它类型组合使用: + +``` +GET /api/v1/finance/by-customer?customer_name_b64={base64url编码的客户名}&record_types=sale,sale_discount,receipt +``` + +### record_types 可选值 + +| 值 | 说明 | 数据来源 | +|---|---|---| +| `sale` | 成品销售单 | I_Sale 表 | +| `sale_return` | 客户退货单 | I_Sale 表 | +| `receipt` | 收款记录(SK%) | F_Skd 表 | +| `refund` | 退款记录(XT%) | F_Skd 表 | +| **`sale_discount`** | **销售折扣记录(XS%)** | **F_Skd 表** ← 新增 | + +### 响应结构 + +新增字段(在原有响应基础上): + +```json +{ + "mode": "customer_finance", + "customer_name": "客户名称", + "customer_ids": ["KH001"], + "record_types": ["sale_discount"], + "status": "active", + "snapshot_at": "2026-05-19T10:00:00Z", + "total_count": 5, + "sale_discounts_count": 5, + "sale_discounts": [ + { + "BianHaoID": "XS20260101-001", + "KhID": "KH001", + "RiQi": "2026-01-01T00:00:00Z", + "KdRiQi": "2026-01-01T00:00:00Z", + "YfJinE": 1000.00, + "FkJinE": 800.00, + "ZkJinE": 200.00, + "JieSunFS": "...", + "YhID": "...", + "ZhaiYao": "...", + "BeiZhu": "..." + } + ] +} +``` + +### sale_discount 每条记录包含的字段 + +| 字段 | 说明 | +|---|---| +| `BianHaoID` | 单据编号(XS 开头) | +| `KhID` | 客户 ID | +| `RiQi` | 日期 | +| `KdRiQi` | 开单日期 | +| `YfJinE` | 应付金额 | +| `FkJinE` | 付款金额 | +| **`ZkJinE`** | **折扣金额(你们需要的字段)** | +| `JieSunFS` | 结算方式 | +| `YhID` | 银行 ID | +| `ZhaiYao` | 摘要 | +| `BeiZhu` | 备注 | + +--- + +## 关于差额 816 的对账建议 + +根据你们的分析: +- receipt.ZkJinE = 233 → 通过 `record_types=receipt` 获取 ✅ +- sales.ZkJinE = 583 → 现在通过 `record_types=sale_discount` 获取 ✅ + +建议对账时同时请求: +``` +record_types=receipt,sale_discount +``` + +然后分别对 `receipts` 和 `sale_discounts` 数组中的 `ZkJinE` 字段求和,即可得到完整的折扣金额。 + +--- + +## 注意事项 + +1. `sale_discount` 不会包含在默认查询中。如果不传 `record_types` 参数,默认只返回 `sale, sale_return, receipt, refund` 四种类型(保持向后兼容)。 +2. 请求 `sale_discount` 时不需要传 `include_cash_movement` 或 `include_adjustments` 参数,这两个参数只对 `receipt` 和 `refund` 类型生效。 +3. `customer_name_b64` 使用 base64url 编码(无 padding),与之前的用法一致。 + +如有疑问请随时联系。 diff --git a/docs/STATEMENT_BUG_FIX_PLAN.md b/docs/STATEMENT_BUG_FIX_PLAN.md new file mode 100644 index 0000000..ffb9754 --- /dev/null +++ b/docs/STATEMENT_BUG_FIX_PLAN.md @@ -0,0 +1,166 @@ +# 客户对账单 Bug 修复计划 + +本文档列出 antd-demo 后端对账单同步与计算中已确认的所有问题,按优先级排序。 + +--- + +## 问题总览 + +| # | 问题 | 影响金额(腾飞案例) | 影响金额(张晓鹏案例) | 优先级 | +|---|------|---:|---:|:---:| +| 1 | 退货单金额放错列 | 7,378 | 17,224 | P0 | +| 2 | 退货单被冗余写入收款桶 | 7,378 | 17,224 | P0 | +| 3 | SfJinE 未按 BianHaoID 去重 | 0 | 197,280 | P0 | +| 4 | 销售折扣(F_Skd XS% 行 ZkJinE)无法获取 | 2 | 583 | P1 | + +--- + +## Bug 1:退货单金额放错列 + +### 现象 + +"外部销售退货单"行的金额被放到了"已收金额"列,应该放到"本单应收"列(负数)。 + +| | antd-demo 当前 | ERP 正确值 | +|---|---|---| +| 本单应收 | 0 | -7,378(负数) | +| 已收金额 | 7,378 | 0 | + +### 影响 + +退货对欠款的减少效果被抵消,导致累欠偏高。 + +### 修复 + +同步 `sale_return` 类型的 `ExternalCustomerStatementOrder` 时: +- `positive_amount` = 0 +- `negative_amount` = |JinE|(取绝对值,如 7,378) + +对账单视图中"本单应收"显示为负数(= positive_amount - negative_amount = -7,378)。 + +--- + +## Bug 2:退货单被冗余写入收款桶 + +### 现象 + +同一笔 XT 退货单同时出现在"外部销售退货单"和"收款单"两种类型里。 + +腾飞纺织:收款单 101 行(应为 92),多出的 9 行 = 退货单。 + +### 影响 + +收款行数虚增,但金额被退货冲减,导致退货效果完全抵消。 + +### 修复 + +同步逻辑中,XT 退货单只写入退货桶(`external_sales_return_order`),不写入收款桶(`receipt_order`)。 + +检查点:`finance/by-customer?record_types=refund` 返回的 XT 记录不应该再创建 `ReceiptOrder`。 + +--- + +## Bug 3:SfJinE 未按 BianHaoID 去重 + +### 现象 + +`I_Sale.SfJinE` 是单据头级别字段,被冗余写到同一 BianHaoID 的每一行明细上。直接对所有明细行求和会重复计入。 + +张晓鹏案例: +- 7 笔现场收款订单,共 22 行明细 +- 去重后 SfJinE = 77,132(正确) +- 直接 SUM = 274,412(错误,多算 197,280) + +### 影响 + +欠款被严重低估(张晓鹏少算了 197,280 元)。 + +### 修复 + +同步时按 BianHaoID 分组,每组只取一次 SfJinE: + +```python +seen = set() +total_sf = 0.0 +for record in sales_records: + if record.BianHaoID not in seen: + seen.add(record.BianHaoID) + total_sf += record.SfJinE +``` + +--- + +## Bug 4:销售折扣数据缺失 + +### 现象 + +`/api/v1/i-sale/by-customer` 返回的 `ZkJinE` 全部为 0。真实的销售折扣存在于 `F_Skd` 表的 XS% 行中,但当前只从 F_Skd 拉 SK%(收款)记录。 + +张晓鹏案例: +- receipt.ZkJinE = 233(已正确获取)✅ +- sales.ZkJinE = 583(存在于 F_Skd XS% 行,无法获取)❌ +- 总差额 = 816 元 + +### 影响 + +欠款偏高 583 元(销售折扣未扣减)。影响较小,大部分客户折扣为 0 或极小。 + +### 解决方案(需 haobuye API 配合) + +**方案 A(推荐)**:让 haobuye API 的 `finance/by-customer` 接口支持 `record_types=sale` + +```http +GET /api/v1/finance/by-customer?customer_name_b64=xxx&record_types=sale +``` + +返回 F_Skd 中 `sType=1 AND BianHaoID LIKE 'XS%'` 的记录,包含 ZkJinE 字段。 + +**方案 B**:用 `db/select` 补查非零折扣 + +```http +GET /api/v1/db/select?table=dbo.F_Skd + &columns=BianHaoID,ZkJinE + &where=KhID='KH00308' AND BianHaoID LIKE 'XS%' AND ZkJinE<>0 + &limit=1000 +``` + +非零折扣行通常很少,不会触及 1000 行上限。 + +**方案 C**:让 haobuye API 修改 `i-sale/by-customer` 接口,按 BianHaoID join F_Skd 把真实 ZkJinE 填入返回值。 + +--- + +## 修复优先级建议 + +```text +第一步:修 Bug 1 + Bug 2(退货相关,影响最大,纯后端改动) +第二步:修 Bug 3(SfJinE 去重,纯后端改动) +第三步:修 Bug 4(需要 haobuye API 配合,影响最小) +``` + +--- + +## 修复后预期结果 + +以腾飞纺织为例: + +| 项目 | 修复前 | 修复后 | ERP 正确值 | +|------|---:|---:|---:| +| 累欠金额 | 60,899 | 53,521 | 53,521 | + +以张晓鹏为例(截至 5-18): + +| 项目 | 修复前 | 修复后(不含 Bug4) | 修复后(含 Bug4) | ERP | +|------|---:|---:|---:|---:| +| 累欠金额 | 748,563 | 748,330 | 747,747 | 747,747 | + +--- + +## 验证方法 + +修复后用以下客户验证: + +1. **木棉(KH01078)**— SfJinE=0,无折扣,预期 10,718 +2. **腾飞纺织(KH00311)**— SfJinE=0,折扣=2,预期 53,521 +3. **金庸(KH00290)**— SfJinE=23,394(1笔1行,不涉及去重),折扣=13,009,预期 82,670 +4. **张晓鹏(KH00308)**— SfJinE=77,132(7笔22行,必须去重),折扣=816,预期与 ERP 实时值一致 diff --git a/docs/business_api_reference.md b/docs/business_api_reference.md index 25d8191..dc60958 100644 --- a/docs/business_api_reference.md +++ b/docs/business_api_reference.md @@ -177,6 +177,7 @@ - `bank_account`:可选,引用 `basic_info.BankAccount`,用于记录具体的付款账户。 - `markup`:可选字符串,用于记录票据附言;与 `remarks`(内部备注)区分。 - `discount_amount`:可选,默认 0,允许大于 `amount`(表示折扣大于实付);必须 ≥ 0。 +- 列表/详情响应额外包含 `is_external_source` 与 `external_source_id`,用于标识是否来自外部系统同步以及对应外部记录 ID。 - `settlement_amount = amount + discount_amount`,所有余额、对账及汇总均基于结算金额。 - `amount` 必须大于 0。返回 201 + 创建的记录,响应中会包含 `discount_amount` 与 `settlement_amount`。 @@ -214,6 +215,7 @@ - `bank_account`:可选,引用到账银行账户。 - `discount_amount`:可选,默认 0,可大于 `amount`,但必须 ≥ 0。 - `markup`:可选,记录回单附言,默认留空。 +- 列表/详情响应额外包含 `is_external_source` 与 `external_source_id`,用于外部收款/退款同步关联与审计。 - `settlement_amount` 为响应只读字段(`amount + discount_amount`),对账及余额只会计算结算金额。 ### 5.2 审批 @@ -290,7 +292,8 @@ - 固定按 `occurred_at -> recorded_at -> source_id` 倒序输出,不提供排序参数。 - `positive_amount` / `negative_amount` 统一表示余额增减;`cumulative_amount`、`current_balance`、`arrears_amount` 均冗余在每条记录中,前端可直接使用。 -- 余额快照来自 `BalanceService`,每次请求只查询一次,保证与审批事务一致。 +- 客户对账单在存在外部 statement-only 业务依据时,可能出现 `external_sales_order` / `external_sales_return_order` 两种新的 `source_type`。 +- 客户对账单在存在外部 statement-only 业务依据时,`current_balance` / `arrears_amount` 会基于“本地余额 + 外部业务来源净额”做临时展示口径修正;余额接口本身仍返回持久化 `CustomerBalance.balance`。 - 单条查询接口需提供 `counterparty_type`、`counterparty_id`、`order_type`、`order_id` 四个 Query 参数,返回 schema 与列表一致,仅 `records` 中包含匹配记录。 --- diff --git a/docs/external_finance_sync.md b/docs/external_finance_sync.md new file mode 100644 index 0000000..8bd32f6 --- /dev/null +++ b/docs/external_finance_sync.md @@ -0,0 +1,276 @@ +# 外部财务同步设计 + +本文档记录 `ReceiptOrder` 对接 HaoBuYe 外部财务 API 的当前实现口径,以及后续批量同步的预备方案。 + +## 1. 当前已实现能力 + +- 单客户同步命令:`python manage.py sync_external_customer_finance <客户名> --operator-id ` +- 仅同步到 `business.ReceiptOrder` +- `PaymentOrder` 当前不参与外部财务同步 +- 命令完成后,会继续同步该客户的外部 `sale` / `sale_return` 业务记录,用于对账单计算 + +### 1.0 使用前前置条件 + +在首次使用前,需要确认以下条件: + +- 已完成数据库迁移,使 `ReceiptOrder` / `PaymentOrder` 拥有 `is_external_source` 与 `external_source_id` 字段 +- 已配置外部 API 访问参数:`HAOBUYE_API_BASE_URL`、`HAOBUYE_API_AUTHORIZATION` +- 已确定一个本地经办人 `Employee.id`,用于创建并审批同步产生的 `ReceiptOrder` + +推荐执行: + +```bash +python manage.py migrate +``` + +如果只想确认本次相关迁移,也可以先查看: + +```bash +python manage.py showmigrations business +``` + +本次同步依赖的迁移文件为: + +- `business.0028_payment_receipt_external_source_fields` +- `business.0029_externalcustomerstatementorder` + +### 1.0.1 外部业务记录的落库策略 + +外部 `sale` / `sale_return` 当前不会落到核心 `SalesOrder` / `SalesReturnOrder`,而是落到专用模型: + +- `business.ExternalCustomerStatementOrder` + +设计目的: + +- 只服务 `build_customer_statement(...)` 的对账计算 +- 避免把外部历史业务单塞进当前 ERP 的库存/审批流程 +- 不触发库存出入库 +- 不修改持久化的 `CustomerBalance` + +说明: + +- 对账单计算时,会把这部分外部业务记录纳入 statement records +- 同时会在 statement 内部临时调整当前余额口径,使“本地收款已同步,但销售历史来自外部”的客户也能得到兼容结果 + +### 1.0.2 i-sale 数据实际存放位置 + +外部 `i-sale/by-customer` 拉到的 `sale` / `sale_return` 数据,当前不会展开成核心 `SalesOrder` / `SalesReturnOrder`,而是存到: + +- 模型:`business.ExternalCustomerStatementOrder` + +其中关键字段为: + +- `category`:`sale` 或 `sale_return` +- `external_customer_id`:外部客户 ID,例如 `KH00308` +- `external_source_id`:外部业务单号,对应 `BianHaoID` +- `occurred_at`:业务日期,对应 `RiQi` +- `recorded_at`:录单时间,对应 `KdRiQi` +- `total_amount`:按同一 `BianHaoID` 聚合后的金额 +- `remarks`:由外部备注字段整理后的文本备注 + +明细与扩展信息使用 JSONField 保存: + +- `items_payload`:明细快照,按外部单据的多行 `I_Sale` 记录聚合后保存 +- `extra_payload`:扩展数据,如 `dan_type`、`raw_count` 等 + +当前 `items_payload` 中保留的信息包括: + +- `product_id`:若能通过 `HpID -> Product.human_id` 匹配到本地产品,则写入本地产品 ID;否则为 `null` +- `product_name`:优先本地产品名,否则回退为外部 `HpID` +- `quantity` +- `price` +- `unit` +- `spec` +- `num_of_rolls` +- `external_sub_id` +- `external_product_id` + +### 1.1 字段映射 + +#### 收款(`SK%`) + +- `BianHaoID` -> `ReceiptOrder.external_source_id` +- `FkJinE` -> `ReceiptOrder.amount` +- `ZkJinE` -> `ReceiptOrder.discount_amount` +- `JieSunFS` -> `ReceiptOrder.markup` +- `RiQi`(回退 `KdRiQi`)-> `ReceiptOrder.receipt_date` + +#### 退款(`XT%`) + +- `BianHaoID` -> `ReceiptOrder.external_source_id` +- `YfJinE` -> `ReceiptOrder.amount` +- `discount_amount` 固定为 `0` +- `JieSunFS` -> `ReceiptOrder.markup` +- `RiQi`(回退 `KdRiQi`)-> `ReceiptOrder.receipt_date` + +说明: + +- `XT%` 当前按负金额 `ReceiptOrder` 落库,表示客户退款。 +- 外部 `refund` 若结算金额为 `0`,当前会跳过并记入 summary,不落库。 +- 为兼容外部“纯折扣收款”(如 `FkJinE=0, ZkJinE>0`),同步层直接创建 `ReceiptOrder` 模型并复用审批逻辑,不走普通创建 API 的 `amount != 0` 限制。 + +### 1.2 幂等规则 + +- 幂等键:`merchant + external_source_id + is_external_source=True` +- 若本地已存在同一 `external_source_id` 且核心字段一致,则跳过 +- 若同一 `external_source_id` 已存在但金额/日期/客户不一致,则报冲突错误,避免静默脏写 + +外部业务记录(statement-only)使用单独幂等键: + +- `merchant + category + external_source_id` + +其中: + +- `category = sale | sale_return` +- `external_source_id = BianHaoID` + +### 1.3 外部业务单来源字段 + +命令在同步收款/退款后,会继续调用: + +- `GET /api/v1/i-sale/by-customer?customer_id=...&category=sale` +- `GET /api/v1/i-sale/by-customer?customer_id=...&category=sale_return` + +当前聚合策略: + +- 按 `BianHaoID` 聚合同一张外部业务单 +- `RiQi` -> statement `occurred_at` +- `KdRiQi` -> statement `recorded_at` +- `JinE` 聚合为外部业务单金额 +- `HpID` 优先匹配本地 `Product.human_id` +- 若未匹配到本地产品,仍保留 `HpID` 作为对账单 item 展示标识,不阻塞同步 + +## 1.4 已同步客户如何补齐 i-sale 业务依据 + +对于“已经同步过收款/退款,但当时还没有 i-sale 接口”的客户,不需要删除任何旧数据,也不需要回滚收款单。 + +正确做法是: + +1. 先执行数据库迁移 +2. 重新执行同一个单客户同步命令 + +例如: + +```bash +python manage.py migrate +python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer +``` + +原因: + +- 已存在的 `ReceiptOrder` 会按既有幂等键跳过,不会重复插入 +- 缺失的 `ExternalCustomerStatementOrder` 会被补齐 +- 已存在的外部业务来源单,也会按 `merchant + category + external_source_id` 跳过 + +因此,补齐老客户时不需要先删除收款/退款数据;“先删再重灌”既不科学,也会增加误删风险。 + +建议: + +- 如果只是想先确认将会补哪些业务依据,可先用 `--dry-run` +- 如果外部同一 `BianHaoID` 的核心字段与本地已存快照不一致,命令会报冲突错误,而不是静默覆盖 +- 真正需要人工处理的场景,应优先核对外部数据是否修订过,而不是直接删除本地记录 + +## 2. 当前命令参数 + +```bash +python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer +``` + +参数说明: + +- `customer_name`:外部客户名称,精确匹配 +- `--operator-id`:本地经办人 `Employee.id`。若不传,则回退使用 `HAOBUYE_FINANCE_SYNC_OPERATOR_ID` +- `--allow-create-customer`:本地没有该客户时自动创建;默认关闭 +- `--dry-run`:只校验,不写入;默认关闭 + +命令行为说明: + +- 如果既没有传 `--operator-id`,也没有配置 `HAOBUYE_FINANCE_SYNC_OPERATOR_ID`,命令会直接报错退出 +- 如果客户不存在且未传 `--allow-create-customer`,命令会直接报错退出 +- 如果外部返回 `status=not_found`,命令不会写入任何数据,而是返回一份空结果 summary +- 如果本地已存在同一个 `external_source_id` 且字段一致,会跳过,不会重复插入 +- 如果本地已存在同一个 `external_source_id` 但核心字段不一致,会报冲突错误,避免静默覆盖 +- 收款/退款同步完成后,命令会继续同步外部 `sale` / `sale_return` 到 `ExternalCustomerStatementOrder` + +常用示例: + +```bash +# 正式同步 +python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer + +# 只做拉取和校验,不写入 +python manage.py sync_external_customer_finance "张晓鹏" --operator-id 12 --allow-create-customer --dry-run + +# 使用环境变量里的默认 operator +python manage.py sync_external_customer_finance "张晓鹏" --allow-create-customer +``` + +命令成功后会输出 summary,典型字段包括: + +- `customer_name` +- `external_customer_id` +- `receipts_seen` +- `refunds_seen` +- `sales_seen` +- `sale_returns_seen` +- `created_count` +- `skipped_existing_count` +- `skipped_zero_settlement_count` +- `external_business_created_count` +- `external_business_skipped_existing_count` +- `dry_run_count` +- `created_receipt_ids` +- `created_external_business_ids` +- `skipped_external_ids` + +对于已同步过财务数据、再次补齐 i-sale 的客户,常见 summary 形态是: + +- `created_count = 0` +- `skipped_existing_count > 0` +- `external_business_created_count > 0` + +这表示: + +- 收款/退款因为已存在而被跳过 +- 但外部业务依据(statement-only)被成功补录 + +也可通过环境变量预配置: + +- `HAOBUYE_API_BASE_URL` +- `HAOBUYE_API_AUTHORIZATION` +- `HAOBUYE_API_TIMEOUT_SECONDS` +- `HAOBUYE_FINANCE_SYNC_OPERATOR_ID` + +推荐最小配置示例: + +```env +HAOBUYE_API_BASE_URL=http://43.139.183.222:18080 +HAOBUYE_API_AUTHORIZATION=your-fixed-authorization-secret +HAOBUYE_API_TIMEOUT_SECONDS=30 +HAOBUYE_FINANCE_SYNC_OPERATOR_ID=12 +``` + +## 3. 已预留的批量能力 + +代码中已实现批量入口: + +- `sync_customer_finance_batch(...)` + +设计原则: + +- 批量模式只负责“枚举客户” +- 单客户同步逻辑完全复用 `sync_customer_finance(...)` +- 当前尚未暴露为 command / celery / cron + +### 3.1 批量模式预期流程 + +1. 调用外部 `GET /api/v1/customers?cursor_id=...` +2. 拿到最多 `10` 个客户 +3. 对每个客户执行 `sync_customer_finance(...)` +4. 整页处理完毕后再推进 `next_cursor_id` + +### 3.2 后续建议 + +- 若进入定时任务阶段,建议将外部 `cursor_id` 持久化到 `api_v1.DataSync` +- 若需要失败补偿,建议单独增加 finance failure model,记录 `customer_name / external_source_id / run_date / error` +- 若前端后续需要区分“收款/退款”,建议在 `ReceiptOrder` 列表 API 上增加只读过滤参数(例如 `amount_sign=positive|negative`),而不是新增第二套退款模型 \ No newline at end of file diff --git a/docs/haobuye-api.md b/docs/haobuye-api.md new file mode 100644 index 0000000..e33315c --- /dev/null +++ b/docs/haobuye-api.md @@ -0,0 +1,1120 @@ +# API Documentation + +Base URL: + +```text +http://: +``` + +Response content type: + +```text +application/json; charset=utf-8 +``` + +## Authorization + +所有 API 都要求固定的 `Authorization` 请求头。 + +配置文件: + +```yaml +auth: + secret: "your-fixed-authorization-secret" +``` + +请求示例: + +```http +Authorization: your-fixed-authorization-secret +``` + +说明: + +- 服务端会把 `Authorization` 头的原始值与 `auth.secret` 做精确匹配 +- 如果你想使用 `Bearer xxx` 风格,也可以直接把 `auth.secret` 配成完整的 `Bearer xxx` + +未授权响应: + +```json +{ + "error": "unauthorized" +} +``` + +## Logging + +服务端会输出两级日志: + +- `WARNING` +- `ERROR` + +当前会重点记录: + +- 未授权访问 +- 参数错误 +- 数据库相关失败 +- 图片查找失败 + +当图片不存在时,日志里会带上尝试过的完整路径,便于排查 Windows 部署目录问题。 + +## 1. Health Check + +`GET /healthz` + +用途: + +- 检查 API 进程是否存活 +- 检查数据库连接是否可用 + +成功响应: + +```json +{ + "ok": true +} +``` + +## 2. Query Records + +`GET /api/v1/records` + +### 2.1 增量同步模式 + +不传 `id_start` / `id_end` 时,接口按当前游标做增量读取: + +- 条件:`ID > cursor` +- 排序:`ORDER BY ID ASC` +- 默认 `limit=100` +- 默认 `update_cursor=true` +- 如果查到数据且 `update_cursor=true`,游标推进到本次结果最后一条记录的 `ID` +- 返回结果会自动补上主表里的 `KhID`,并额外附带客户表 `dbo.B_Khzl` 的客户信息 +- 返回结果会根据客户表 `KHLbID` 自动关联地区表 `dbo.B_Khlb`,并平铺返回 `area` +- 返回结果也会根据订单表 `HpID` 自动关联布料表 `dbo.B_Hpzl`,并平铺返回 `HpName` + +参数: + +- `update_cursor`: 可选,布尔值,默认 `true` +- `limit`: 可选,正整数,默认 `100` + +示例: + +```http +GET /api/v1/records +Authorization: your-fixed-authorization-secret +``` + +```http +GET /api/v1/records?update_cursor=false&limit=20 +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "incremental", + "cursor_before": 1200, + "cursor_after": 1250, + "origin_cursor": 1000, + "last_record_id": 1250, + "updated": true, + "count": 50, + "records": [ + { + "ID": 1201, + "KhID": 88, + "BianHaoID": "...", + "area": "华东地区", + "HpName": "某某布料", + "customer": { + "KhID": 88, + "KhName": "某某客户" + } + } + ] +} +``` + +字段说明: + +- `mode`: 固定为 `incremental` +- `cursor_before`: 本次请求开始前的游标 +- `cursor_after`: 本次请求完成后的游标。如果 `update_cursor=false`,这里会返回本次结果最后一条记录的 ID,但不会实际写入状态文件 +- `origin_cursor`: 最近一次手工修改游标前的值;自动增量同步不会改它 +- `last_record_id`: 本次返回结果中的最后一条记录 ID;无数据时不存在 +- `updated`: 本次是否实际更新了游标状态 +- `count`: 返回记录数 +- `records[].KhID`: 主表记录中的客户 ID +- `records[].customer`: 客户表补充信息,当前默认包含 `KhID` 和 `KhName` +- `records[].area`: 客户地区,来自 `dbo.B_Khlb` 的 `MingCheng` +- `records[].HpName`: 布料表 `dbo.B_Hpzl` 中关联到的布料名称 + +### 2.2 范围查询模式 + +传了 `id_start` 和 `id_end` 时,接口进入范围查询模式: + +- 条件:`ID >= id_start AND ID <= id_end` +- 排序:`ORDER BY ID ASC` +- 不会更新游标 +- 必须显式传 `update_cursor=false` + +参数: + +- `id_start`: 必填,非负整数 +- `id_end`: 必填,非负整数 +- `update_cursor`: 必填,且必须为 `false` +- `limit`: 可选,正整数,默认 `100` + +示例: + +```http +GET /api/v1/records?id_start=2000&id_end=2100&update_cursor=false +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "range", + "cursor_before": 1250, + "cursor_after": 1250, + "origin_cursor": 1000, + "last_record_id": 2050, + "updated": false, + "count": 51, + "id_start": 2000, + "id_end": 2100, + "records": [ + { + "ID": 2000, + "BianHaoID": "..." + } + ] +} +``` + +### 2.3 参数校验规则 + +- `limit` 必须大于 0 +- `id_start` 和 `id_end` 必须同时出现 +- `id_start` 和 `id_end` 必须是非负整数 +- `id_start` 不能大于 `id_end` +- 范围查询模式必须显式传 `update_cursor=false` + +## 3. Query Current Order Snapshot + +`GET /api/v1/records/by-order` + +用途: + +- 按 `external_order_id` 精确查询当前整单快照 +- 返回该 `BianHaoID` 下当前全部 records +- 不读取 cursor,也不推进 cursor +- 返回字段会继续附带现有的 `customer`、`area`、`HpName` + +参数: + +- `external_order_id`: 必填,字符串,精确对应主表 `D_KhDhd.BianHaoID` + +示例: + +```http +GET /api/v1/records/by-order?external_order_id=KD20432358 +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "snapshot", + "external_order_id": "KD20432358", + "status": "active", + "snapshot_at": "2026-04-16T10:12:34Z", + "total_count": 3, + "records": [ + { + "ID": 1000001, + "BianHaoID": "KD20432358", + "KhID": "KH00999", + "HpName": "120克本白四面弹单定", + "area": "周边", + "customer": { + "KhID": "KH00999", + "KhName": "鸿烨服饰" + } + } + ] +} +``` + +字段说明: + +- `mode`: 固定为 `snapshot` +- `external_order_id`: 当前查询的订单号 +- `status`: 当前快照状态;查询命中时固定为 `active` +- `snapshot_at`: 服务端生成本次快照响应的时间 +- `total_count`: 当前订单快照下 records 数量 +- `records`: 当前订单下的全部 records,字段宽度与现有 `GET /api/v1/records` 保持一致 + +未命中响应示例: + +```json +{ + "external_order_id": "KD20432358", + "status": "not_found", + "message": "未找到该 external_order_id 对应的订单" +} +``` + +## 4. Query Customer Orders + +`GET /api/v1/records/by-customer` + +用途: + +- 按客户名精确查询该客户名下全部订单 +- 底层先查询 `D_KhDhd` 明细,再按 `BianHaoID` 聚合为订单主体 +- 每个订单主体下的明细结构与现有 `GET /api/v1/records/by-order` 完全一致 +- 不读取 cursor,也不推进 cursor +- 结果按配置项 `customer_orders.sort_column` 倒序排列,默认 `KdRiQi` + +参数: + +- `customer_name_b64`: 必填,客户名的 Base64URL 编码;精确匹配客户表 `dbo.B_Khzl.KhName` + +示例: + +```http +GET /api/v1/records/by-customer?customer_name_b64=6bi_54OI5pyN6aWw +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "customer_orders", + "customer_name": "鸿烨服饰", + "status": "active", + "snapshot_at": "2026-05-09T10:12:34Z", + "total_orders": 2, + "orders": [ + { + "mode": "snapshot", + "external_order_id": "KD20432358", + "status": "active", + "snapshot_at": "2026-05-09T10:12:34Z", + "total_count": 2, + "records": [ + { + "ID": 1000001, + "BianHaoID": "KD20432358", + "KhID": "KH00999", + "HpName": "120克本白四面弹单定", + "area": "周边", + "customer": { + "KhID": "KH00999", + "KhName": "鸿烨服饰" + } + } + ] + } + ] +} +``` + +字段说明: + +- `mode`: 固定为 `customer_orders` +- `customer_name`: 当前查询的客户名 +- `status`: 查询命中时固定为 `active` +- `snapshot_at`: 服务端生成本次响应的时间 +- `total_orders`: 当前客户名下聚合后的订单数 +- `orders`: 订单列表;每个订单元素的结构与 `GET /api/v1/records/by-order` 返回结构保持一致 + +未命中响应示例: + +```json +{ + "customer_name": "鸿烨服饰", + "status": "not_found", + "message": "未找到该 customer_name 对应的订单" +} +``` + +配置: + +```yaml +customer_orders: + sort_column: "KdRiQi" +``` + +- `sort_column` 用于控制订单聚合结果的排序字段,默认值为 `KdRiQi` + +## 5. Query Customer List + +`GET /api/v1/customers` + +用途: + +- 按客户主档表 `dbo.B_Khzl` 拉取客户列表 +- 每次固定返回 `10` 个客户 +- 只返回 `KhID` 和 `KhName` +- 用于外部系统按客户维度做同步 +- 使用游标参数 `cursor_id` 续拉,不使用 `page` / `pageSize` + +排序与游标规则: + +- 底层按 `ZZRQ ASC, KhID ASC` 排序 +- `ZZRQ` 越早的客户越先返回,避免从新到旧翻页时遗漏新数据 +- `cursor_id` 传上一页最后一条客户的 `KhID` +- 服务端会先查出该 `KhID` 对应的 `ZZRQ`,再从 `(ZZRQ, KhID)` 这个排序位置之后继续取下一批 `10` 条 +- 如果多个客户 `ZZRQ` 相同,会继续用 `KhID` 做稳定次序 + +参数: + +- `cursor_id`: 可选,上一页最后一条客户的 `KhID` + +示例: + +```http +GET /api/v1/customers +Authorization: your-fixed-authorization-secret +``` + +```http +GET /api/v1/customers?cursor_id=KH01485 +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "customers", + "cursor_id": "KH01485", + "next_cursor_id": "KH01495", + "snapshot_at": "2026-05-14T15:00:00Z", + "count": 10, + "customers": [ + { + "customer_id": "KH01486", + "customer_name": "A亿佳服饰" + } + ] +} +``` + +字段说明: + +- `mode`: 固定为 `customers` +- `cursor_id`: 本次请求使用的游标;首批为空 +- `next_cursor_id`: 本页最后一条客户的 `KhID`;下一页继续传它即可 +- `snapshot_at`: 服务端生成本次响应的时间 +- `count`: 当前返回客户数,最大固定为 `10` +- `customers[].customer_id`: 客户 ID,对应 `dbo.B_Khzl.KhID` +- `customers[].customer_name`: 客户名称,对应 `dbo.B_Khzl.KhName` + +未命中游标响应示例: + +```json +{ + "cursor_id": "KH99999", + "status": "not_found", + "message": "未找到该 cursor_id 对应的客户" +} +``` + +## 6. Query I_Sale Records By BianHaoID + +`GET /api/v1/i-sale/by-bianhao` + +用途: + +- 按 `BianHaoID` 查询 `dbo.I_Sale` 中的业务明细记录 +- 为收款和退款记录补充对应业务单据的信息 +- 返回 `I_Sale` 的全量字段 +- 不读取 cursor,也不推进 cursor + +注意: + +- 同一个 `BianHaoID` 在 `I_Sale` 中可能命中多条明细行 +- 因此该接口返回的是 `records[]`,不是单对象详情 + +参数: + +- `bianhao_id`: 必填,精确匹配 `dbo.I_Sale.BianHaoID` + +示例: + +```http +GET /api/v1/i-sale/by-bianhao?bianhao_id=XT20203911 +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "i_sale_records", + "bianhao_id": "XT20203911", + "status": "active", + "snapshot_at": "2026-05-14T15:10:00Z", + "total_count": 1, + "records": [ + { + "BianHaoID": "XT20203911", + "DanType": "客户退货单", + "KhID": "KH01204" + } + ] +} +``` + +字段说明: + +- `mode`: 固定为 `i_sale_records` +- `bianhao_id`: 当前查询的业务单号 +- `status`: 查询命中时固定为 `active` +- `snapshot_at`: 服务端生成本次响应的时间 +- `total_count`: 当前 `BianHaoID` 命中的 `I_Sale` 明细行数 +- `records`: `I_Sale` 全量字段明细列表 + +未命中响应示例: + +```json +{ + "bianhao_id": "XT404", + "status": "not_found", + "message": "未找到该 bianhao_id 对应的 I_Sale 记录" +} +``` + +## 7. Query I_Sale Records By Customer + +`GET /api/v1/i-sale/by-customer` + +用途: + +- 按客户 ID 查询 `dbo.I_Sale` 中的业务明细记录 +- 用于补足按客户统计时对 `I_Sale` 的直接拉取能力 +- 不分页、不使用游标;一次返回当前客户在指定分类下的全部命中记录 +- 返回字段不是 `I_Sale` 全 119 列,而是“核心标识 + 全部金额字段 + 全部数量字段 + 备注字段” + +当前分类策略: + +- `sale`:只取 `DanType = '成品销售单' AND BianHaoID LIKE 'XS%'` +- `sale_return`:只取 `DanType = '客户退货单' AND BianHaoID LIKE 'XT%'` + +当前实现决策: + +- 用户明确接受低频调用,因此本接口不做分页和游标 +- 但为了避免把 `I_Sale` 全 119 列直接暴露给同步方,当前只返回与统计/业务核对最相关的字段组 +- 该字段组已结合项目内 `I_SALE_FIELD_ANALYSIS.md` 与线上 `db/columns` 验证 + +参数: + +- `customer_id`: 必填,精确匹配 `dbo.I_Sale.KhID` +- `category`: 必填,当前只支持 `sale`、`sale_return` + +示例: + +```http +GET /api/v1/i-sale/by-customer?customer_id=KH00308&category=sale +Authorization: your-fixed-authorization-secret +``` + +```http +GET /api/v1/i-sale/by-customer?customer_id=KH00308&category=sale_return +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "i_sale_customer_records", + "customer_id": "KH00308", + "category": "sale", + "status": "active", + "snapshot_at": "2026-05-14T20:00:00Z", + "total_count": 2, + "records": [ + { + "SubID": 1, + "BianHaoID": "XS20367431", + "DanType": "成品销售单", + "KhID": "KH00308", + "RiQi": "2026-05-14T00:00:00Z", + "KdRiQi": "2026-05-14T19:08:23Z", + "JieSunFS": "欠款", + "HpID": "HP00001", + "JianShu": "1.00", + "ShuLiang": "20.00", + "ShuLiangT": "20.00", + "DanJia": "32.000000", + "JinE": "640.000000", + "DingJin": "0.00", + "YfJinE": "0.00", + "SfJinE": "0.00", + "ZkJinE": "0.00", + "LjQK": "0.00", + "BeiZhu": "", + "BeiZhuC": "", + "BeiZhuD": "", + "MeoD": "普通单据" + } + ] +} +``` + +字段说明: + +- `mode`: 固定为 `i_sale_customer_records` +- `customer_id`: 当前查询的客户 ID +- `category`: 当前查询的分类 +- `status`: 查询命中时固定为 `active` +- `snapshot_at`: 服务端生成本次响应的时间 +- `total_count`: 当前客户在该分类下命中的 `I_Sale` 明细行数 +- `records`: 当前字段集包含以下几类字段 + +核心标识字段: + +- `SubID` +- `BianHaoID` +- `DanType` +- `KhID` +- `RiQi` +- `KdRiQi` +- `JieSunFS` +- `HpID` +- `JiJiaDW` + +数量相关字段: + +- `JianShu` +- `ShuLiang` +- `ShuLiangZ` +- `ShuLiangK` +- `ShuLiangS` +- `JianShuT` +- `ShuLiangT` +- `ShuLiangZT` +- `ShuLiangKT` +- `ShuLiangST` +- `Sl1` ~ `Sl10` + +金额相关字段: + +- `DingJin` +- `YfJinE` +- `SfJinE` +- `ZkJinE` +- `LjQK` +- `DanJia` +- `JinE` +- `DanJiaT` +- `JinET` +- `DanJiaCB` +- `JinECB` +- `JinE_SL` + +备注相关字段: + +- `BeiZhu` +- `BeiZhuC` +- `BeiZhuD` +- `MeoA` +- `MeoB` +- `MeoC` +- `MeoD` + +未命中响应示例: + +```json +{ + "customer_id": "KH00308", + "category": "sale", + "status": "not_found", + "message": "未找到该 customer_id 和 category 对应的 I_Sale 记录" +} +``` + +参数错误: + +- `customer_id` 为空时返回 `400` +- `category` 非 `sale` / `sale_return` 时返回 `400` + +## 7. Query Customer Finance Records + +`GET /api/v1/finance/by-customer` + +用途: + +- 按客户名精确查询该客户名下的财务记录 +- 当前销售/销退只认 `dbo.I_Sale` +- 当前收款/退款只认 `dbo.F_Skd` +- 支持按记录类型筛选,并支持控制是否包含真实资金变动、是否包含挂账/欠款调整 +- 不读取 cursor,也不推进 cursor + +参数: + +- `customer_name_b64`: 必填,客户名的 Base64URL 编码;精确匹配客户表 `dbo.B_Khzl.KhName` +- `record_types`: 可选,逗号分隔;支持 `sale`、`sale_return`、`receipt`、`refund`;不传默认全部 +- `include_cash_movement`: 可选,布尔值;默认 `true` +- `include_adjustments`: 可选,布尔值;默认 `false` +- `filter_sk`: 可选,布尔值;默认 `false`;当前 `receipt` 口径下保留该参数但不生效 + +当前口径: + +- `sale`: `I_Sale.DanType = '成品销售单'` +- `sale_return`: `I_Sale.DanType = '客户退货单'` +- `receipt`: 仅纳入 `F_Skd.BianHaoID LIKE 'SK%'` 的收款流水,不纳入 `XS%` 记录 +- `refund`: `F_Skd.sType = 1 AND F_Skd.BianHaoID LIKE 'XT%'` +- `receipt` 的返回金额字段仍保留 `F_Skd.FkJinE`、`F_Skd.ZkJinE` 等原始值,但当前口径不再使用 `FkJinE` 作为筛选条件 +- `refund` 的统计和筛选金额字段为 `F_Skd.YfJinE` +- `include_cash_movement` 与 `include_adjustments` 当前仅影响 `refund`;`receipt` 统一按 `SK%` 全量统计 +- `include_cash_movement=true` 时,`refund` 取 `YfJinE < 0` +- `include_adjustments=true` 时,`refund` 额外包含 `YfJinE = 0` 的挂账/欠款调整记录 + +当前已验证的 `sType` 规则: + +- `sType = 1`:当前财务 API 使用的业务单据关联流水,样本中覆盖 `XS...` 与 `XT...` +- `sType = 0`:当前样本对应 `SK...` 型独立收款流水;当前 `receipt` 查询会纳入它,`refund` 不会 + +示例: + +```http +GET /api/v1/finance/by-customer?customer_name_b64=6bi_54OI5pyN6aWw&record_types=sale,receipt,refund&include_cash_movement=true&include_adjustments=false +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "mode": "customer_finance", + "customer_name": "鸿烨服饰", + "customer_ids": [ + "KH00999" + ], + "record_types": [ + "sale", + "receipt", + "refund" + ], + "include_cash_movement": true, + "include_adjustments": false, + "status": "active", + "snapshot_at": "2026-05-10T21:30:00Z", + "total_count": 3, + "sales_count": 1, + "sale_returns_count": 0, + "receipts_count": 1, + "refunds_count": 1, + "sales": [ + { + "BianHaoID": "XS20366749", + "DanType": "成品销售单", + "KhID": "KH00999" + } + ], + "sale_returns": [], + "receipts": [ + { + "BianHaoID": "XS20215718", + "FkJinE": "378.00", + "JieSunFS": "微信" + } + ], + "refunds": [ + { + "BianHaoID": "XT20200743", + "YfJinE": "-2201.00", + "FkJinE": "0.00", + "JieSunFS": "微信" + } + ] +} +``` + +未命中响应示例: + +```json +{ + "customer_name": "鸿烨服饰", + "status": "not_found", + "message": "未找到该 customer_name 对应的财务记录" +} +``` + +参数错误示例: + +```json +{ + "error": "record_types contains unsupported value" +} +``` + +## 6. Database Inspect Permission Check + +`GET /api/v1/db/permissions` + +用途: + +- 检查当前数据库账号是否具备数据库分析所需的基础能力 +- 尝试验证数据库连接、获取当前库名、列出可见表、读取字段元数据、读取 `sys` 元数据、检查 `VIEW DEFINITION`、以及对所有可见表执行 `SELECT TOP (1)` +- 返回总体是否可用,以及各项检查结果 + +配置开关: + +```yaml +db_inspect: + enabled: true +``` + +成功响应示例: + +```json +{ + "can_inspect": true, + "database_name": "RCYH2020", + "visible_table_count": 32, + "checks": [ + { + "name": "database_connection", + "ok": true + }, + { + "name": "list_base_tables", + "ok": true + }, + { + "name": "read_all_visible_table_columns", + "ok": true + }, + { + "name": "select_top_1_from_all_visible_tables", + "ok": true + } + ] +} +``` + +字段说明: + +- `can_inspect`: 是否满足数据库分析所需的整体权限和可读能力 +- `database_name`: 当前连接到的数据库名 +- `visible_table_count`: 当前账号可见的基础表数量 +- `checks`: 分项检查结果 +- `failed_column_tables`: 无法读取字段元数据的表清单,仅检查失败时出现 +- `failed_select_tables`: 无法执行 `SELECT TOP (1)` 的表清单,仅检查失败时出现 + +## 7. List Database Tables + +`GET /api/v1/db/tables` + +用途: + +- 返回当前数据库中当前账号可见的所有基础表 + +成功响应示例: + +```json +{ + "count": 2, + "tables": [ + { + "schema": "dbo", + "name": "D_KhDhd", + "full_name": "dbo.D_KhDhd" + }, + { + "schema": "dbo", + "name": "B_Khzl", + "full_name": "dbo.B_Khzl" + } + ] +} +``` + +## 8. List Table Columns + +`GET /api/v1/db/columns` + +参数: + +- `table`: 必填,表名,格式为 `schema.table` 或 `db.schema.table` + +用途: + +- 返回指定表的字段列表 +- 返回字段名、类型、是否可空、长度、精度、顺序等信息 + +示例: + +```http +GET /api/v1/db/columns?table=dbo.D_KhDhd +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "table": "dbo.D_KhDhd", + "count": 2, + "columns": [ + { + "name": "ID", + "data_type": "int", + "nullable": false, + "ordinal_position": 1 + }, + { + "name": "BianHaoID", + "data_type": "nvarchar", + "nullable": true, + "max_length": 50, + "ordinal_position": 2 + } + ] +} +``` + +## 9. Generic Table Select + +`GET /api/v1/db/select` + +参数: + +- `table`: 必填,表名,格式为 `schema.table` 或 `db.schema.table` +- `columns`: 必填,逗号分隔的字段名列表,例如 `ID,BianHaoID` +- `where`: 可选,受限条件表达式;支持 `=` `!=` `<>` `>` `>=` `<` `<=` `LIKE` `IS NULL` `IS NOT NULL`,多个条件只支持用 `AND` 连接;字符串字面量必须写成单引号,例如 `DanType = '客户退货单'` +- `limit`: 可选,正整数;不传时使用默认值,并受服务端上限限制 + +用途: + +- 对指定表执行通用只读查询 +- 仅返回请求中指定的字段集合 +- 当前实现固定为 `SELECT TOP (limit)`,支持受限 `WHERE`,不支持自定义 SQL + +示例: + +```http +GET /api/v1/db/select?table=dbo.D_KhDhd&columns=ID,BianHaoID&limit=10 +Authorization: your-fixed-authorization-secret +``` + +```http +GET /api/v1/db/select?table=dbo.I_Sale&columns=BianHaoID,DanType,ShuLiang&where=DanType%20%3D%20%27%E5%AE%A2%E6%88%B7%E9%80%80%E8%B4%A7%E5%8D%95%27%20AND%20ShuLiang%20%3C%200&limit=10 +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "table": "dbo.D_KhDhd", + "columns": [ + "ID", + "BianHaoID" + ], + "count": 2, + "rows": [ + { + "ID": 1001, + "BianHaoID": "KD20432358" + }, + { + "ID": 1002, + "BianHaoID": "KD20432359" + } + ] +} +``` + +参数错误示例: + +```json +{ + "error": "where is invalid" +} +``` + +当配置关闭时: + +```json +{ + "error": "db inspect api is disabled" +} +``` + +## 10. Get Current Cursor + +`GET /api/v1/cursor` + +用途: + +- 查看当前游标值 +- 查看最近一次人工修改游标前的 `origin_cursor` + +示例: + +```http +GET /api/v1/cursor +Authorization: your-fixed-authorization-secret +``` + +成功响应示例: + +```json +{ + "cursor": 1500, + "origin_cursor": 1250 +} +``` + +## 11. Set Cursor + +`POST /api/v1/cursor/set` + +用途: + +- 人工把游标改到指定值 +- 每次手工修改时,都会把修改前的游标保存到 `origin_cursor` + +请求体: + +```json +{ + "value": 1500 +} +``` + +规则: + +- `value` 必须是非负整数 +- 调用成功后: + - `cursor` 更新为 `value` + - `origin_cursor` 保存修改前的 `cursor` + +请求示例: + +```http +POST /api/v1/cursor/set +Authorization: your-fixed-authorization-secret +Content-Type: application/json + +{ + "value": 1500 +} +``` + +成功响应示例: + +```json +{ + "cursor_before": 1250, + "cursor_after": 1500, + "origin_cursor": 1250, + "updated": true +} +``` + +## 12. Get Image + +`GET /api/v1/image` + +用途: + +- 根据给定文件名,从配置的图片目录读取对应文件 +- 找到后返回图片内容的 Base64 字符串 +- 找不到时返回 `404` + +配置文件: + +```yaml +image: + directory: "./images" +``` + +参数: + +- `name`: 可选,原始文件名,必须是纯文件名,不能包含路径 +- `name_b64`: 可选,文件名的 Base64URL 编码;当文件名包含 `#` 等 URL 特殊字符时推荐使用 +- 如果 `name` 不带后缀,服务端会按 `.jpg`、`.png` 的顺序自动尝试 + +说明: + +- `name` 和 `name_b64` 二选一即可 +- 如果文件名里包含 `#`,很多客户端会在真正发请求前把它后面的内容当作 URL fragment 截掉,因此推荐使用 `name_b64` + +示例: + +```http +GET /api/v1/image?name=test +Authorization: your-fixed-authorization-secret +``` + +```http +GET /api/v1/image?name_b64=YWJjIzEyMw +Authorization: your-fixed-authorization-secret +``` + +上面的 `name_b64=YWJjIzEyMw` 对应原始文件名: + +```text +abc#123 +``` + +成功响应示例: + +```json +{ + "name": "test.jpg", + "content_type": "image/jpeg", + "data": "/9j/4AAQSkZJRgABAQ..." +} +``` + +错误响应示例: + +```json +{ + "error": "image not found" +} +``` + +说明: + +- `data` 是图片二进制内容的 Base64 编码 +- `content_type` 会优先根据文件扩展名推断,推断不出来时会回退到内容检测 +- 如果 `name` 没有后缀,会优先查找 `name.jpg`,找不到再查 `name.png` +- 如果文件名中含有 `#`、空格等特殊字符,优先使用 `name_b64` +- 为了安全,`name` 不能带目录分隔符,也不能包含 `..` + +## 13. Cursor State File + +默认文件: + +```text +./data/cursor-state.json +``` + +文件内容示例: + +```json +{ + "cursor": 1500, + "origin_cursor": 1250 +} +``` + +说明: + +- `cursor`: 当前增量同步游标 +- `origin_cursor`: 最近一次人工修改游标前的值 diff --git a/docs/statements.md b/docs/statements.md index a00ee26..8be6e24 100644 --- a/docs/statements.md +++ b/docs/statements.md @@ -6,13 +6,31 @@ ## 1. 功能概览 -- **客户对账单**:聚合指定客户的销售单、销售退货单、收款单,统一展示正负金额、当前余额以及累欠金额。 +- **客户对账单**:聚合指定客户的销售单、销售退货单、收款单,以及外部来源的 statement-only 业务依据,统一展示正负金额、当前余额以及累欠金额。 - **供应商对账单**:聚合指定供应商的采购单、采购退货单、付款单,展示待付金额的增减与余额。 - **排序规则**:固定按 `occurred_at -> recorded_at -> source_id` 倒序排列,不支持外部修改。 - **金额方向**: - 客户:销售单记入 `positive_amount`(增加应收),销售退货/收款记入 `negative_amount`(减少应收)。 - 供应商:采购单记入 `positive_amount`(增加待付),采购退货/付款记入 `negative_amount`(减少待付)。 -- **余额来源**:调用 `BalanceService`,每次请求仅查询一次,并冗余在每条记录中,便于前端表格或统计组件使用。 +- **余额来源**:供应商对账单直接使用余额表;客户对账单在存在外部 statement-only 业务依据时,会基于余额表做一次“仅用于 statement 展示”的临时口径修正。 + +### 1.1 外部 statement-only 业务依据 + +当客户的历史 `sale` / `sale_return` 来自外部系统、且不希望落入核心 `SalesOrder` / `SalesReturnOrder` 时,系统会把它们存入: + +- `business.ExternalCustomerStatementOrder` + +这些记录: + +- 只参与客户对账单计算 +- 不参与库存流转 +- 不参与核心销售单审批链 +- 不修改持久化 `CustomerBalance` + +在对账单响应中,它们会以以下 `source_type` 出现: + +- `external_sales_order` +- `external_sales_return_order` --- @@ -41,8 +59,8 @@ ```json { - "customer": 6, - "customer_name": "杭州零售商", + "counterparty": 6, + "counterparty_name": "杭州零售商", "records": [ { "source_type": "sales_order", @@ -104,18 +122,58 @@ | `cumulative_amount` | 当前记录输出前累计的对账金额(正负抵消)。 | | `current_balance` | 余额表的最新应收/待付款快照;每条记录重复提供,便于表格展示。 | | `arrears_amount` | 累欠金额,等于 `current_balance - cumulative_amount`,表示该记录时刻的实时欠款/待付款。 | +| `remarks` | 统一备注字段。所有 record 都会返回;无备注时为空字符串。 | | `extra` | 预留字段,后续可扩展批次、仓库等信息。 | | `summary` | 记录集中正负金额的求和,供前端快速展示。 | +对于外部 statement-only 业务依据,`extra` 当前会额外包含: + +- `external_source_id` +- `external_customer_id` +- `settlement_method` + +对客户对账单来说,当前还可能出现两种新的 `source_type`: + +- `external_sales_order` +- `external_sales_return_order` + +`remarks` 的来源统一如下: + +- 本地采购/销售/退货/收付款单:直接取对应单据的 `remarks` +- 外部 statement-only 业务依据:取 `ExternalCustomerStatementOrder.remarks`,其中会包含同步时整理过的 `BeiZhu` / `BeiZhuC` / `BeiZhuD` / `MeoD` 文本 + --- ## 4. 业务规则 1. **审批依赖**:只有审批通过的单据才会出现在对账单中;审批完成后若需冲销则需走红冲流程。 -2. **数据一致性**:在审批事务内同步写入 `BalanceService` 及 `BalanceChangeRecord`,对账单直接基于这些模型计算,因此账面数据与业务状态保持一致。 +2. **数据一致性**:在纯本地业务场景下,对账单直接基于审批后的业务单据和余额表计算,因此账面数据与业务状态保持一致。 3. **幂等保障**:`BalanceService` 在审批中使用行级锁和 `select_for_update`,避免重复写入;对账单查询为只读操作,不影响事务。 4. **扩展字段**:如需在对账单中加入汇总、备注、仓库信息,可在视图构建 `extra` 字段或通过 serializer context 注入新的统计字段。 +### 4.1 客户余额口径边界 + +这是本次修正最重要的边界说明。 + +当客户存在 `ExternalCustomerStatementOrder` 时: + +- `GET /customers//balance/` 仍返回数据库中持久化的 `CustomerBalance.balance` +- `GET /customers//statements/` 中每条记录的 `current_balance` / `arrears_amount`,会基于“本地余额 + 外部业务来源净额”做一次临时口径修正 + +因此,在存在外部 statement-only 业务依据的客户上: + +- 余额接口和对账单接口的余额展示,可能暂时不完全一致 + +这是当前设计的有意结果,因为: + +- 我们需要让对账单拥有完整的“应收业务依据” +- 但又不希望把外部历史业务单写入核心销售链,进而污染库存与审批语义 + +换句话说: + +- `CustomerBalance` 代表 ERP 内部正式余额账 +- 客户对账单在此场景下代表“兼容外部历史业务依据后的展示口径” + --- ## 5. 场景示例 diff --git a/env.example b/env.example index 4c03056..b3070f5 100644 --- a/env.example +++ b/env.example @@ -81,6 +81,15 @@ MESSAGE_API_BASE_URL= MESSAGE_API_DEFAULT_NEWS_IMAGE_URL=https://via.placeholder.com/640x360.png?text=No+Image SPEAK_ENDPOINT=http://8.148.215.233:9004/speak +############################ +# HaoBuYe 外部财务同步(可选) +############################ +HAOBUYE_API_BASE_URL= +HAOBUYE_API_AUTHORIZATION= +HAOBUYE_API_TIMEOUT_SECONDS=30 +# 可选:作为外部财务同步默认经办人(Employee.id) +HAOBUYE_FINANCE_SYNC_OPERATOR_ID=0 + # PrintingJob 状态推进通知的“跟进地址”模板;为空则消息里省略“跟进地址”字段 PRINTING_JOB_STATE_ADVANCED_FOLLOWUP_URL_TEMPLATE=https://app.yuwen.cloud/workstation/production/batch-advance?orderId={order_id} diff --git a/flower/settings.py b/flower/settings.py index 33e0d70..3da8f57 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -94,6 +94,12 @@ SPEAK_ENDPOINT = env('SPEAK_ENDPOINT', default='http://8.148.215.233:9004/speak' PRINTING_ORDER_CREATED_SPEECH_ENABLED = False AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='') +# HaoBuYe 外部财务同步 +HAOBUYE_API_BASE_URL = env('HAOBUYE_API_BASE_URL', default='http://43.139.183.222:18080') +HAOBUYE_API_AUTHORIZATION = env('HAOBUYE_API_AUTHORIZATION', default='your-fixed-authorization-secret') +HAOBUYE_API_TIMEOUT_SECONDS = env.float('HAOBUYE_API_TIMEOUT_SECONDS', default=30.0) +HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', default=0) + # CORS 配置 CORS_ALLOW_ALL_ORIGINS = True # 开发环境允许所有源,生产环境需要配置白名单 diff --git a/mission/payload_processors.py b/mission/payload_processors.py index de6daa6..160b385 100644 --- a/mission/payload_processors.py +++ b/mission/payload_processors.py @@ -3,7 +3,7 @@ import re from mission.models import MissionPayloadProcessorEnum -STRUCTURED_DESCRIPTION_IMAGE_PREFIX = "款式图:" +STRUCTURED_DESCRIPTION_IMAGE_PREFIXES = ("款式图:", "款式图:") def _extract_labeled_image_url(lines: list[str]) -> tuple[list[str], str]: @@ -11,12 +11,18 @@ def _extract_labeled_image_url(lines: list[str]) -> tuple[list[str], str]: image_url = "" for line in lines: stripped_line = line.strip() - if stripped_line.startswith(STRUCTURED_DESCRIPTION_IMAGE_PREFIX): - candidate = stripped_line.removeprefix(STRUCTURED_DESCRIPTION_IMAGE_PREFIX).strip() - if candidate: - image_url = candidate + matched_prefix = None + for prefix in STRUCTURED_DESCRIPTION_IMAGE_PREFIXES: + if stripped_line.startswith(prefix): + matched_prefix = prefix + break + if matched_prefix is None: + kept_lines.append(line) continue - kept_lines.append(line) + + candidate = stripped_line.removeprefix(matched_prefix).strip() + if candidate: + image_url = candidate return kept_lines, image_url