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