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

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

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

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