1
0
forked from erp-dev/erp

feat: correct first

This commit is contained in:
2026-05-19 23:41:34 +08:00
parent f409f2e6ee
commit b97e86257e
25 changed files with 3885 additions and 25 deletions

View File

@@ -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/<id>/review/`
- `{"action": "approve"}``{"action": "cancel"}`
@@ -136,6 +141,11 @@
审批通过表示“确认收款”,作废则恢复为初始状态。
列表/详情响应同样包含:
- `is_external_source`
- `external_source_id`
---
## 6. 错误示例

View File

@@ -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',
]

View File

@@ -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',
]

View File

@@ -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)

View File

@@ -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',)

View File

@@ -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)

View File

@@ -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)))

View File

@@ -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='是否外部来源'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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='折扣金额'),
),
]

View File

@@ -982,6 +982,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:
return [int(value) for value in self.quantity_of_rolls.split(',') if value.strip()]
@@ -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:

View File

@@ -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
# 已收只展示 FkJinEorder.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

View File

@@ -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())

View File

@@ -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')

View File

@@ -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 的每一行明细上。
示例(张晓鹏 XS202057885 行明细):
```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 1SfJinE 未去重
当前同步代码对 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 |

View File

@@ -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: <deployment auth secret>
```
说明:
- 本仓库不写入真实鉴权密钥。
- 实际值请从目标环境部署配置中的 `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=<BASE64URL_NAME>&record_types=sale
Authorization: <deployment auth secret>
```
### Only Sale Returns
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=sale_return
Authorization: <deployment auth secret>
```
### Only Receipts With Real Cash Movement
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=receipt&include_cash_movement=true&include_adjustments=false
Authorization: <deployment auth secret>
```
### Only Refunds With Real Cash Movement
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=refund&include_cash_movement=true&include_adjustments=false
Authorization: <deployment auth secret>
```
### Only Refund Adjustments
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=refund&include_cash_movement=false&include_adjustments=true
Authorization: <deployment auth secret>
```
### Sales And Receipts Together
```http
GET /api/v1/finance/by-customer?customer_name_b64=<BASE64URL_NAME>&record_types=sale,receipt&include_cash_movement=true&include_adjustments=false
Authorization: <deployment auth secret>
```
## 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. 如果用户要求复杂统计,必须拒绝,并说明当前只支持原始命中记录查询,以及必要时基于返回结果做简单累计或计数。

View File

@@ -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与之前的用法一致。
如有疑问请随时联系。

View File

@@ -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 3SfJinE 未按 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 3SfJinE 去重,纯后端改动)
第三步:修 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,3941笔1行不涉及去重折扣=13,009预期 82,670
4. **张晓鹏KH00308**— SfJinE=77,1327笔22行必须去重折扣=816预期与 ERP 实时值一致

View File

@@ -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` 中包含匹配记录。
---

View File

@@ -0,0 +1,276 @@
# 外部财务同步设计
本文档记录 `ReceiptOrder` 对接 HaoBuYe 外部财务 API 的当前实现口径,以及后续批量同步的预备方案。
## 1. 当前已实现能力
- 单客户同步命令:`python manage.py sync_external_customer_finance <客户名> --operator-id <Employee.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`),而不是新增第二套退款模型

1120
docs/haobuye-api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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/<id>/balance/` 仍返回数据库中持久化的 `CustomerBalance.balance`
- `GET /customers/<id>/statements/` 中每条记录的 `current_balance` / `arrears_amount`,会基于“本地余额 + 外部业务来源净额”做一次临时口径修正
因此,在存在外部 statement-only 业务依据的客户上:
- 余额接口和对账单接口的余额展示,可能暂时不完全一致
这是当前设计的有意结果,因为:
- 我们需要让对账单拥有完整的“应收业务依据”
- 但又不希望把外部历史业务单写入核心销售链,进而污染库存与审批语义
换句话说:
- `CustomerBalance` 代表 ERP 内部正式余额账
- 客户对账单在此场景下代表“兼容外部历史业务依据后的展示口径”
---
## 5. 场景示例

View File

@@ -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}

View File

@@ -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 # 开发环境允许所有源,生产环境需要配置白名单

View File

@@ -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()
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
candidate = stripped_line.removeprefix(matched_prefix).strip()
if candidate:
image_url = candidate
continue
kept_lines.append(line)
return kept_lines, image_url