forked from erp-dev/erp
394 lines
16 KiB
Python
394 lines
16 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',
|
|
'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())
|