forked from erp-dev/erp
feat: correct first
This commit is contained in:
@@ -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. 错误示例
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',)
|
||||
|
||||
|
||||
|
||||
841
business/external_finance_sync.py
Normal file
841
business/external_finance_sync.py
Normal 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)
|
||||
@@ -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)))
|
||||
@@ -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='是否外部来源'),
|
||||
),
|
||||
]
|
||||
42
business/migrations/0029_externalcustomerstatementorder.py
Normal file
42
business/migrations/0029_externalcustomerstatementorder.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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='折扣金额'),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -1903,6 +1903,8 @@ STATEMENT_ORDER_TYPE_CHOICES = (
|
||||
('sales_order', '销售单'),
|
||||
('sales_return_order', '销售退货单'),
|
||||
('receipt_order', '收款单'),
|
||||
('external_sales_order', '外部销售单'),
|
||||
('external_sales_return_order', '外部销售退货单'),
|
||||
('purchase_order', '采购单'),
|
||||
('purchase_return_order', '采购退货单'),
|
||||
('payment_order', '付款单'),
|
||||
@@ -1917,9 +1919,10 @@ def build_customer_statement(
|
||||
"""
|
||||
根据客户历史单据生成对账记录,供多个 API 复用。
|
||||
"""
|
||||
balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer)
|
||||
builder = _CustomerStatementBuilder(merchant=merchant, current_balance=balance)
|
||||
local_balance = BalanceService.get_customer_balance(merchant=merchant, customer=customer)
|
||||
builder = _CustomerStatementBuilder(merchant=merchant, current_balance=local_balance)
|
||||
records = builder.collect_records(customer)
|
||||
builder.adjust_current_balance(builder.external_balance_adjustment)
|
||||
return builder.build_payload(
|
||||
counterparty_id=customer.id,
|
||||
counterparty_name=customer.name,
|
||||
@@ -1975,6 +1978,10 @@ class _StatementBuilder:
|
||||
self._current_balance_value = _normalize_statement_amount(current_balance)
|
||||
self._current_balance_display = _decimal_to_string(self._current_balance_value)
|
||||
|
||||
def adjust_current_balance(self, delta: Decimal) -> None:
|
||||
self._current_balance_value += _normalize_statement_amount(delta)
|
||||
self._current_balance_display = _decimal_to_string(self._current_balance_value)
|
||||
|
||||
def collect_records(self, counterparty) -> List[dict]: # pragma: no cover - interface only
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -2008,6 +2015,7 @@ class _StatementBuilder:
|
||||
warehouse: basic_info_models.WareHouse | None = None,
|
||||
positive_amount,
|
||||
negative_amount,
|
||||
remarks: str | None = '',
|
||||
items: List[dict] | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> dict:
|
||||
@@ -2026,6 +2034,7 @@ class _StatementBuilder:
|
||||
'warehouse': warehouse,
|
||||
'positive_amount': _normalize_statement_amount(positive_amount),
|
||||
'negative_amount': _normalize_statement_amount(negative_amount),
|
||||
'remarks': remarks or '',
|
||||
'items': items,
|
||||
}
|
||||
if extra:
|
||||
@@ -2107,11 +2116,16 @@ class _StatementBuilder:
|
||||
|
||||
|
||||
class _CustomerStatementBuilder(_StatementBuilder):
|
||||
def __init__(self, *, merchant: basic_info_models.Merchant, current_balance: Decimal):
|
||||
super().__init__(merchant=merchant, current_balance=current_balance)
|
||||
self.external_balance_adjustment = Decimal('0')
|
||||
|
||||
def collect_records(self, customer: basic_info_models.Customer) -> List[dict]:
|
||||
records: List[dict] = []
|
||||
records.extend(self._build_sales_records(customer))
|
||||
records.extend(self._build_sales_return_records(customer))
|
||||
records.extend(self._build_receipt_records(customer))
|
||||
records.extend(self._build_external_statement_records(customer))
|
||||
return records
|
||||
|
||||
def _build_sales_records(self, customer: basic_info_models.Customer) -> List[dict]:
|
||||
@@ -2141,6 +2155,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
|
||||
warehouse=order.warehouse,
|
||||
positive_amount=order.get_total_amount(),
|
||||
negative_amount=_STATEMENT_ZERO,
|
||||
remarks=order.remarks,
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
@@ -2173,6 +2188,7 @@ class _CustomerStatementBuilder(_StatementBuilder):
|
||||
warehouse=order.warehouse,
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
remarks=order.remarks,
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
@@ -2189,6 +2205,9 @@ class _CustomerStatementBuilder(_StatementBuilder):
|
||||
)
|
||||
records = []
|
||||
for order in qs:
|
||||
# ERP 口径:收款单的 ZkJinE 从应收侧扣减(负的 positive_amount),
|
||||
# 已收只展示 FkJinE(order.amount)。净效果与 settlement_amount 一致。
|
||||
discount = order.discount_amount or Decimal('0')
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.customer_id,
|
||||
@@ -2200,8 +2219,55 @@ class _CustomerStatementBuilder(_StatementBuilder):
|
||||
recorded_at=order.created_at,
|
||||
status=order.status,
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
positive_amount=-discount,
|
||||
negative_amount=order.amount,
|
||||
remarks=order.remarks,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
def _build_external_statement_records(self, customer: basic_info_models.Customer) -> List[dict]:
|
||||
qs = models.ExternalCustomerStatementOrder.objects.filter(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
).order_by('occurred_at', 'recorded_at', 'id')
|
||||
records = []
|
||||
for order in qs:
|
||||
sf = order.sf_amount or Decimal('0')
|
||||
zk = order.zk_amount or Decimal('0')
|
||||
if order.category == models.ExternalCustomerStatementCategoryEnum.SALE:
|
||||
source_type = 'external_sales_order'
|
||||
source_label = '外部销售单'
|
||||
# 净应收 = 毛额 - 折扣;现场收款作为 negative_amount 减少欠款
|
||||
positive_amount = order.total_amount - zk
|
||||
negative_amount = sf
|
||||
else:
|
||||
source_type = 'external_sales_return_order'
|
||||
source_label = '外部销售退货单'
|
||||
# 退货减少应收:退货金额 + 退货折扣都减少欠款
|
||||
positive_amount = _STATEMENT_ZERO
|
||||
negative_amount = order.total_amount + zk
|
||||
self.external_balance_adjustment += positive_amount - negative_amount
|
||||
records.append(
|
||||
self._build_record(
|
||||
counterparty_id=order.customer_id,
|
||||
counterparty_name=order.customer.name,
|
||||
source_type=source_type,
|
||||
source_label=source_label,
|
||||
source_id=order.id,
|
||||
occurred_at=order.occurred_at,
|
||||
recorded_at=order.recorded_at or order.created_at,
|
||||
status=models.ReceiptOrderStatusEnum.APPROVED,
|
||||
status_label='已同步',
|
||||
positive_amount=positive_amount,
|
||||
negative_amount=negative_amount,
|
||||
remarks=order.remarks,
|
||||
items=list(order.items_payload or []),
|
||||
extra={
|
||||
'external_source_id': order.external_source_id,
|
||||
'external_customer_id': order.external_customer_id,
|
||||
'settlement_method': order.settlement_method,
|
||||
},
|
||||
)
|
||||
)
|
||||
return records
|
||||
@@ -2242,6 +2308,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
|
||||
warehouse=order.warehouse,
|
||||
positive_amount=order.get_total_amount(),
|
||||
negative_amount=_STATEMENT_ZERO,
|
||||
remarks=order.remarks,
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
@@ -2274,6 +2341,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
|
||||
warehouse=order.warehouse,
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
remarks=order.remarks,
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
@@ -2303,6 +2371,7 @@ class _SupplierStatementBuilder(_StatementBuilder):
|
||||
status_label=order.get_status_display(),
|
||||
positive_amount=_STATEMENT_ZERO,
|
||||
negative_amount=order.get_total_amount(),
|
||||
remarks=order.remarks,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
393
business/tests/test_external_finance_sync.py
Normal file
393
business/tests/test_external_finance_sync.py
Normal 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())
|
||||
@@ -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')
|
||||
|
||||
221
docs/CUSTOMER_DEBT_CALCULATION(1).md
Normal file
221
docs/CUSTOMER_DEBT_CALCULATION(1).md
Normal 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 的每一行明细上。
|
||||
|
||||
示例(张晓鹏 XS20205788,5 行明细):
|
||||
|
||||
```text
|
||||
SubID=1 JinE=380 SfJinE=22,287 ← 同一个值
|
||||
SubID=2 JinE=11,668 SfJinE=22,287 ← 重复
|
||||
SubID=3 JinE=3,864 SfJinE=22,287 ← 重复
|
||||
SubID=4 JinE=3,206 SfJinE=22,287 ← 重复
|
||||
SubID=5 JinE=3,169 SfJinE=22,287 ← 重复
|
||||
```
|
||||
|
||||
正确做法:每个 BianHaoID 只取一次 SfJinE = 22,287。
|
||||
错误做法:直接 SUM 所有行 = 22,287 × 5 = 111,435(多算 4 倍)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 不参与计算的字段
|
||||
|
||||
| 字段 | 所在表 | 原因 |
|
||||
|------|--------|------|
|
||||
| I_Sale.YfJinE | I_Sale | 全部为 0 |
|
||||
| I_Sale.DingJin | I_Sale | 订金标记,实际入账已通过 SK/SfJinE 体现 |
|
||||
| I_Sale.JinET | I_Sale | 信息字段,不参与欠款 |
|
||||
| I_Sale.LjQK | I_Sale | 滚动累欠快照,不是增量 |
|
||||
| I_Sale.DanJiaCB / JinECB / JinE_SL | I_Sale | 全部为 0 |
|
||||
| F_Skd.YfJinE | F_Skd | XS 行与 I_Sale.JinE 重复;XT 行与退货重复 |
|
||||
| F_Skd.LjJinE | F_Skd | 累计金额,不参与求和 |
|
||||
| F_Skd.DjJinE / JyJinE | F_Skd | 订金/结余,不参与 |
|
||||
| refund 桶 | F_Skd | 与 sale_returns.JinE 等值,不重复计入 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 对应 API 接口
|
||||
|
||||
| 桶 | 接口 | 参数 |
|
||||
|----|------|------|
|
||||
| 销售单 | GET /api/v1/i-sale/by-customer | customer_id=KHxxxxx&category=sale |
|
||||
| 销售退货 | GET /api/v1/i-sale/by-customer | customer_id=KHxxxxx&category=sale_return |
|
||||
| 收款单 | GET /api/v1/finance/by-customer | customer_name_b64=xxx&record_types=receipt |
|
||||
|
||||
所有接口无分页限制,一次返回全集。
|
||||
|
||||
Base URL: `http://43.139.183.222:18080`
|
||||
Auth: `Authorization: your-fixed-authorization-secret`
|
||||
|
||||
---
|
||||
|
||||
## 5. ERP 对账单滚动累计逻辑
|
||||
|
||||
ERP 对账单按时间排序,每行的"结欠金额"是滚动累计:
|
||||
|
||||
```text
|
||||
结欠[n] = 结欠[n-1] + 本行应收金额 - 本行已收金额
|
||||
```
|
||||
|
||||
- XS 销售单:应收 = JinE,已收 = SfJinE(现场收款,多数为 0)
|
||||
- SK 收款单:应收 = 0(或负数折扣),已收 = FkJinE
|
||||
- XT 退货单:应收 = JinE(负数),已收 = 0
|
||||
|
||||
---
|
||||
|
||||
## 6. 折扣与 ERP "应收金额"的关系
|
||||
|
||||
ERP 展示的"应收金额"是净应收(JinE - ZkJinE),我们从 I_Sale.JinE 拿到的是毛额。
|
||||
|
||||
```text
|
||||
ERP 视角: 欠款 = Σ(应收_净) - Σ(已收)
|
||||
API 视角: 欠款 = Σ(JinE) - Σ(FkJinE) - Σ(SfJinE去重) - Σ(ZkJinE)
|
||||
```
|
||||
|
||||
两者数学等价。
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证结果(全部精确对上)
|
||||
|
||||
| 客户 | KhID | API 欠款 | ERP 欠款 | 差额 |
|
||||
|------|------|---:|---:|---:|
|
||||
| 木棉 | KH01078 | 10,718 | 10,718 | 0 |
|
||||
| 腾飞纺织 | KH00311 | 53,521 | 53,521 | 0 |
|
||||
| 金庸 | KH00290 | 82,670 | 82,670 | 0 |
|
||||
| 张晓鹏 | KH00308 | 753,897 | 753,897 | 0 |
|
||||
|
||||
各客户 SfJinE 情况:
|
||||
|
||||
| 客户 | SfJinE(去重后) | 笔数 | 说明 |
|
||||
|------|---:|---:|------|
|
||||
| 木棉 | 0 | 0 | 无现场收款 |
|
||||
| 腾飞纺织 | 0 | 0 | 无现场收款 |
|
||||
| 金庸 | 23,394 | 1 笔 | XS20214770 微信收款 |
|
||||
| 张晓鹏 | 77,132 | 7 笔 | 2020-09 期间支付宝/微信收款 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 伪代码
|
||||
|
||||
```python
|
||||
def calculate_customer_debt(customer_id: str, customer_name_b64: str) -> float:
|
||||
# 1. 拉销售单
|
||||
sales = api.get("/api/v1/i-sale/by-customer",
|
||||
customer_id=customer_id, category="sale")
|
||||
|
||||
# 2. 拉销售退货
|
||||
sale_returns = api.get("/api/v1/i-sale/by-customer",
|
||||
customer_id=customer_id, category="sale_return")
|
||||
|
||||
# 3. 拉收款单
|
||||
receipts = api.get("/api/v1/finance/by-customer",
|
||||
customer_name_b64=customer_name_b64, record_types="receipt")
|
||||
|
||||
# 4. 计算 AR(每行明细的 JinE 独立求和)
|
||||
ar = sum(r.JinE for r in sales.records) + sum(r.JinE for r in sale_returns.records)
|
||||
|
||||
# 5. 收款
|
||||
received_sk = sum(r.FkJinE for r in receipts.receipts)
|
||||
|
||||
# 6. 现场收款(按 BianHaoID 去重,每单只取一次)
|
||||
seen = set()
|
||||
received_inline = 0.0
|
||||
for r in sales.records:
|
||||
if r.BianHaoID not in seen:
|
||||
seen.add(r.BianHaoID)
|
||||
received_inline += r.SfJinE
|
||||
|
||||
# 7. 折扣
|
||||
discount = (sum(r.ZkJinE for r in sales.records)
|
||||
+ sum(r.ZkJinE for r in sale_returns.records)
|
||||
+ sum(r.ZkJinE for r in receipts.receipts))
|
||||
|
||||
# 8. 欠款
|
||||
return ar - received_sk - received_inline - discount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 按时间窗口计算
|
||||
|
||||
```python
|
||||
def calculate_debt_as_of(customer_id, customer_name_b64, cutoff_date):
|
||||
# 拉取同上,然后按 RiQi < cutoff_date 过滤
|
||||
sales_f = [r for r in sales if r.RiQi < cutoff_date]
|
||||
sr_f = [r for r in sale_returns if r.RiQi < cutoff_date]
|
||||
rcp_f = [r for r in receipts if r.RiQi < cutoff_date]
|
||||
|
||||
ar = sum(r.JinE for r in sales_f) + sum(r.JinE for r in sr_f)
|
||||
received_sk = sum(r.FkJinE for r in rcp_f)
|
||||
|
||||
seen = set()
|
||||
received_inline = 0.0
|
||||
for r in sales_f:
|
||||
if r.BianHaoID not in seen:
|
||||
seen.add(r.BianHaoID)
|
||||
received_inline += r.SfJinE
|
||||
|
||||
discount = (sum(r.ZkJinE for r in sales_f)
|
||||
+ sum(r.ZkJinE for r in sr_f)
|
||||
+ sum(r.ZkJinE for r in rcp_f))
|
||||
|
||||
return ar - received_sk - received_inline - discount
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. antd-demo 后端待修复的 Bug
|
||||
|
||||
### Bug 1:SfJinE 未去重
|
||||
|
||||
当前同步代码对 I_Sale 明细行直接求和 SfJinE,导致多行明细的订单被重复计入。
|
||||
|
||||
修复:按 BianHaoID 分组,每组只取一次 SfJinE。
|
||||
|
||||
### Bug 2:退货单金额放错列
|
||||
|
||||
退货金额被放到"已收金额"(negative_amount),应该放到"本单应收"(positive_amount=0, negative_amount=|JinE|,使得应收为负数)。
|
||||
|
||||
### Bug 3:退货单被冗余写入收款桶
|
||||
|
||||
9 条 XT 退货单同时出现在"外部销售退货单"和"收款单"两种类型里,导致收款行数多了 9 条。
|
||||
|
||||
修复:同步时 XT 退货只写入退货桶,不写入收款桶。
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 内容 |
|
||||
|------|------|
|
||||
| 2026-05-18 | 初版,木棉/腾飞纺织验证通过 |
|
||||
| 2026-05-18 | 金庸验证发现 SfJinE 必须参与扣减 |
|
||||
| 2026-05-19 | **最终版**:张晓鹏验证发现 SfJinE 必须按 BianHaoID 去重。四个客户全部精确对上 ERP |
|
||||
257
docs/FINANCE-API-AGENT-HANDOFF.md
Normal file
257
docs/FINANCE-API-AGENT-HANDOFF.md
Normal 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. 如果用户要求复杂统计,必须拒绝,并说明当前只支持原始命中记录查询,以及必要时基于返回结果做简单累计或计数。
|
||||
117
docs/SALE-DISCOUNT-API-UPDATE.md
Normal file
117
docs/SALE-DISCOUNT-API-UPDATE.md
Normal 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),与之前的用法一致。
|
||||
|
||||
如有疑问请随时联系。
|
||||
166
docs/STATEMENT_BUG_FIX_PLAN.md
Normal file
166
docs/STATEMENT_BUG_FIX_PLAN.md
Normal 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 3:SfJinE 未按 BianHaoID 去重
|
||||
|
||||
### 现象
|
||||
|
||||
`I_Sale.SfJinE` 是单据头级别字段,被冗余写到同一 BianHaoID 的每一行明细上。直接对所有明细行求和会重复计入。
|
||||
|
||||
张晓鹏案例:
|
||||
- 7 笔现场收款订单,共 22 行明细
|
||||
- 去重后 SfJinE = 77,132(正确)
|
||||
- 直接 SUM = 274,412(错误,多算 197,280)
|
||||
|
||||
### 影响
|
||||
|
||||
欠款被严重低估(张晓鹏少算了 197,280 元)。
|
||||
|
||||
### 修复
|
||||
|
||||
同步时按 BianHaoID 分组,每组只取一次 SfJinE:
|
||||
|
||||
```python
|
||||
seen = set()
|
||||
total_sf = 0.0
|
||||
for record in sales_records:
|
||||
if record.BianHaoID not in seen:
|
||||
seen.add(record.BianHaoID)
|
||||
total_sf += record.SfJinE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 4:销售折扣数据缺失
|
||||
|
||||
### 现象
|
||||
|
||||
`/api/v1/i-sale/by-customer` 返回的 `ZkJinE` 全部为 0。真实的销售折扣存在于 `F_Skd` 表的 XS% 行中,但当前只从 F_Skd 拉 SK%(收款)记录。
|
||||
|
||||
张晓鹏案例:
|
||||
- receipt.ZkJinE = 233(已正确获取)✅
|
||||
- sales.ZkJinE = 583(存在于 F_Skd XS% 行,无法获取)❌
|
||||
- 总差额 = 816 元
|
||||
|
||||
### 影响
|
||||
|
||||
欠款偏高 583 元(销售折扣未扣减)。影响较小,大部分客户折扣为 0 或极小。
|
||||
|
||||
### 解决方案(需 haobuye API 配合)
|
||||
|
||||
**方案 A(推荐)**:让 haobuye API 的 `finance/by-customer` 接口支持 `record_types=sale`
|
||||
|
||||
```http
|
||||
GET /api/v1/finance/by-customer?customer_name_b64=xxx&record_types=sale
|
||||
```
|
||||
|
||||
返回 F_Skd 中 `sType=1 AND BianHaoID LIKE 'XS%'` 的记录,包含 ZkJinE 字段。
|
||||
|
||||
**方案 B**:用 `db/select` 补查非零折扣
|
||||
|
||||
```http
|
||||
GET /api/v1/db/select?table=dbo.F_Skd
|
||||
&columns=BianHaoID,ZkJinE
|
||||
&where=KhID='KH00308' AND BianHaoID LIKE 'XS%' AND ZkJinE<>0
|
||||
&limit=1000
|
||||
```
|
||||
|
||||
非零折扣行通常很少,不会触及 1000 行上限。
|
||||
|
||||
**方案 C**:让 haobuye API 修改 `i-sale/by-customer` 接口,按 BianHaoID join F_Skd 把真实 ZkJinE 填入返回值。
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级建议
|
||||
|
||||
```text
|
||||
第一步:修 Bug 1 + Bug 2(退货相关,影响最大,纯后端改动)
|
||||
第二步:修 Bug 3(SfJinE 去重,纯后端改动)
|
||||
第三步:修 Bug 4(需要 haobuye API 配合,影响最小)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 修复后预期结果
|
||||
|
||||
以腾飞纺织为例:
|
||||
|
||||
| 项目 | 修复前 | 修复后 | ERP 正确值 |
|
||||
|------|---:|---:|---:|
|
||||
| 累欠金额 | 60,899 | 53,521 | 53,521 |
|
||||
|
||||
以张晓鹏为例(截至 5-18):
|
||||
|
||||
| 项目 | 修复前 | 修复后(不含 Bug4) | 修复后(含 Bug4) | ERP |
|
||||
|------|---:|---:|---:|---:|
|
||||
| 累欠金额 | 748,563 | 748,330 | 747,747 | 747,747 |
|
||||
|
||||
---
|
||||
|
||||
## 验证方法
|
||||
|
||||
修复后用以下客户验证:
|
||||
|
||||
1. **木棉(KH01078)**— SfJinE=0,无折扣,预期 10,718
|
||||
2. **腾飞纺织(KH00311)**— SfJinE=0,折扣=2,预期 53,521
|
||||
3. **金庸(KH00290)**— SfJinE=23,394(1笔1行,不涉及去重),折扣=13,009,预期 82,670
|
||||
4. **张晓鹏(KH00308)**— SfJinE=77,132(7笔22行,必须去重),折扣=816,预期与 ERP 实时值一致
|
||||
@@ -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` 中包含匹配记录。
|
||||
|
||||
---
|
||||
|
||||
276
docs/external_finance_sync.md
Normal file
276
docs/external_finance_sync.md
Normal 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
1120
docs/haobuye-api.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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. 场景示例
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 # 开发环境允许所有源,生产环境需要配置白名单
|
||||
|
||||
@@ -3,7 +3,7 @@ import re
|
||||
from mission.models import MissionPayloadProcessorEnum
|
||||
|
||||
|
||||
STRUCTURED_DESCRIPTION_IMAGE_PREFIX = "款式图:"
|
||||
STRUCTURED_DESCRIPTION_IMAGE_PREFIXES = ("款式图:", "款式图:")
|
||||
|
||||
|
||||
def _extract_labeled_image_url(lines: list[str]) -> tuple[list[str], str]:
|
||||
@@ -11,12 +11,18 @@ def _extract_labeled_image_url(lines: list[str]) -> tuple[list[str], str]:
|
||||
image_url = ""
|
||||
for line in lines:
|
||||
stripped_line = line.strip()
|
||||
if stripped_line.startswith(STRUCTURED_DESCRIPTION_IMAGE_PREFIX):
|
||||
candidate = stripped_line.removeprefix(STRUCTURED_DESCRIPTION_IMAGE_PREFIX).strip()
|
||||
if candidate:
|
||||
image_url = candidate
|
||||
matched_prefix = None
|
||||
for prefix in STRUCTURED_DESCRIPTION_IMAGE_PREFIXES:
|
||||
if stripped_line.startswith(prefix):
|
||||
matched_prefix = prefix
|
||||
break
|
||||
if matched_prefix is None:
|
||||
kept_lines.append(line)
|
||||
continue
|
||||
kept_lines.append(line)
|
||||
|
||||
candidate = stripped_line.removeprefix(matched_prefix).strip()
|
||||
if candidate:
|
||||
image_url = candidate
|
||||
return kept_lines, image_url
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user