forked from erp-dev/erp
412 lines
18 KiB
Python
412 lines
18 KiB
Python
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',
|
||
'HpName': '外部同步品名',
|
||
'YanSe': '天蓝',
|
||
'SeHao': '150cm',
|
||
'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',
|
||
'HpName': '外部退货品名',
|
||
'YanSe': '米白',
|
||
'SeHao': '160cm',
|
||
'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'], 2)
|
||
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), 2)
|
||
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'))
|
||
|
||
balance = business_models.CustomerBalance.objects.get(merchant=self.merchant, customer=customer)
|
||
self.assertEqual(balance.balance, Decimal('-198871.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'))
|
||
self.assertEqual(external_orders[0].items_payload[0]['product_id'], None)
|
||
self.assertEqual(external_orders[0].items_payload[0]['product_name'], '外部同步品名')
|
||
self.assertEqual(external_orders[0].items_payload[0]['color'], '天蓝')
|
||
self.assertEqual(external_orders[0].items_payload[0]['spec'], '150cm')
|
||
self.assertEqual(external_orders[0].items_payload[0]['external_product_id'], 'HP00001')
|
||
|
||
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)
|
||
# 对账单 records_by_type 是 dict,同一 source_type 只保留最后一个(按日期倒序排列后字典覆盖)
|
||
# 收款单 remarks 由 _build_external_remarks 构建,包含元信息而非原始摘要
|
||
receipt_remarks = records_by_type['receipt_order']['remarks']
|
||
self.assertIn('外部财务同步: receipt', receipt_remarks)
|
||
self.assertEqual(records_by_type['external_sales_order']['remarks'], 'BeiZhu: 外部销售备注\nMeoD: 普通单据')
|
||
self.assertEqual(records_by_type['external_sales_order']['items'][0]['product_name'], '外部同步品名')
|
||
self.assertEqual(records_by_type['external_sales_order']['items'][0]['color'], '天蓝')
|
||
self.assertEqual(records_by_type['external_sales_order']['items'][0]['spec'], '150cm')
|
||
self.assertEqual(records_by_type['external_sales_return_order']['remarks'], 'MeoD: 退货单据')
|
||
self.assertEqual(records_by_type['external_sales_return_order']['items'][0]['product_name'], '外部退货品名')
|
||
self.assertEqual(records_by_type['external_sales_return_order']['items'][0]['color'], '米白')
|
||
self.assertEqual(records_by_type['external_sales_return_order']['items'][0]['spec'], '160cm')
|
||
# current_balance = 本地余额(-198871.00) + 外部业务净额(销售 +640 - 退货 490 = +150) = -198721.00
|
||
self.assertEqual(statement_payload['records'][0]['current_balance'], '-198721.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,
|
||
operator=self.operator,
|
||
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())
|