1
0
forked from erp-dev/erp

fea: business completed

This commit is contained in:
2026-06-06 14:14:50 +08:00
parent b97e86257e
commit 3b2aacaa5c
12 changed files with 1270 additions and 92 deletions

View File

@@ -0,0 +1,217 @@
"""
企业微信用户绑定 API 测试
"""
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from basic_info.models import Employee, Merchant, MerchantTypeEnum
@override_settings(AGENT_ACCESS_KEY='test-key-123')
class WecomBindingAPITestCase(TestCase):
"""测试企业微信用户绑定接口"""
def setUp(self):
self.client = APIClient()
self.url = '/api/v2/wecom/binduser/'
self.auth_header = 'test-key-123'
# 创建商户
self.merchant = Merchant.objects.create(
name='测试商户',
type=MerchantTypeEnum.STORE,
)
# 创建用户
self.user = User.objects.create_user(
username='testuser',
password='testpass123',
)
# 创建员工并关联用户
self.employee = Employee.objects.create(
merchant=self.merchant,
name='测试员工',
sys_user=self.user,
)
def _post(self, data, auth=None):
"""辅助方法:发送 POST 请求"""
headers = {'HTTP_AUTHORIZATION': auth or self.auth_header}
return self.client.post(self.url, data, format='json', **headers)
# ==================== 认证测试 ====================
def test_missing_authorization_header(self):
"""缺少 Authorization 头应返回 401"""
resp = self.client.post(self.url, {}, format='json')
self.assertEqual(resp.status_code, 401)
def test_invalid_authorization_key(self):
"""错误的 AGENT_ACCESS_KEY 应返回 401"""
resp = self._post(
{'wecom_user_id': 'wx123', 'username': 'testuser', 'password': 'testpass123'},
auth='wrong-key',
)
self.assertEqual(resp.status_code, 401)
# ==================== 参数验证测试 ====================
def test_missing_required_fields(self):
"""缺少必填字段应返回 400"""
resp = self._post({})
self.assertEqual(resp.status_code, 400)
data = resp.json()
self.assertIn('wecom_user_id', data)
self.assertIn('username', data)
self.assertIn('password', data)
def test_missing_wecom_user_id(self):
"""缺少 wecom_user_id 应返回 400"""
resp = self._post({'username': 'testuser', 'password': 'testpass123'})
self.assertEqual(resp.status_code, 400)
self.assertIn('wecom_user_id', resp.json())
# ==================== 用户凭据验证测试 ====================
def test_wrong_password(self):
"""密码错误应返回 401"""
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'testuser',
'password': 'wrongpass',
})
self.assertEqual(resp.status_code, 401)
self.assertEqual(resp.json()['error'], '用户名或密码错误')
def test_nonexistent_username(self):
"""不存在的用户名应返回 401"""
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'nouser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 401)
def test_inactive_user(self):
"""被禁用的用户应返回 403"""
self.user.is_active = False
self.user.save()
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'testuser',
'password': 'testpass123',
})
# Django authenticate() returns None for inactive users by default
# so it will be 401 (用户名或密码错误)
self.assertIn(resp.status_code, [401, 403])
def test_user_without_employee(self):
"""用户未关联员工应返回 404"""
user2 = User.objects.create_user(username='noemployee', password='pass123')
resp = self._post({
'wecom_user_id': 'wx123',
'username': 'noemployee',
'password': 'pass123',
})
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.json()['error'], '该用户未关联任何员工记录')
# ==================== 绑定成功测试 ====================
def test_binding_success(self):
"""正常绑定应返回 200"""
resp = self._post({
'wecom_user_id': 'wx_user_001',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['message'], '绑定成功')
self.assertEqual(data['employee_id'], self.employee.id)
self.assertEqual(data['employee_name'], '测试员工')
self.assertEqual(data['wecom_user_id'], 'wx_user_001')
# 验证数据库
self.employee.refresh_from_db()
self.assertEqual(self.employee.wecom_user_id, 'wx_user_001')
def test_binding_idempotent(self):
"""重复绑定相同 wecom_user_id 应幂等返回 200"""
self.employee.wecom_user_id = 'wx_user_001'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_user_001',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['message'], '绑定成功')
# ==================== 冲突测试 ====================
def test_wecom_user_id_already_bound_to_other_employee(self):
"""wecom_user_id 已绑定到其他员工应返回 409"""
other_employee = Employee.objects.create(
merchant=self.merchant,
name='其他员工',
wecom_user_id='wx_user_001',
)
resp = self._post({
'wecom_user_id': 'wx_user_001',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 409)
self.assertIn('该企业微信用户已绑定到其他员工', resp.json()['error'])
def test_employee_already_has_different_wecom_user_id_without_force(self):
"""员工已有不同 wecom_user_id 且未传 force_update 应返回 409"""
self.employee.wecom_user_id = 'wx_old_user'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_new_user',
'username': 'testuser',
'password': 'testpass123',
})
self.assertEqual(resp.status_code, 409)
data = resp.json()
self.assertEqual(data['error'], '该员工已绑定其他企业微信用户')
self.assertEqual(data['current_wecom_user_id'], 'wx_old_user')
self.assertIn('hint', data)
def test_employee_already_has_different_wecom_user_id_with_force(self):
"""员工已有不同 wecom_user_id 但传了 force_update=true 应覆盖成功"""
self.employee.wecom_user_id = 'wx_old_user'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_new_user',
'username': 'testuser',
'password': 'testpass123',
'force_update': True,
})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['wecom_user_id'], 'wx_new_user')
self.employee.refresh_from_db()
self.assertEqual(self.employee.wecom_user_id, 'wx_new_user')
def test_force_update_false_explicitly(self):
"""显式传 force_update=false 应拒绝覆盖"""
self.employee.wecom_user_id = 'wx_old_user'
self.employee.save()
resp = self._post({
'wecom_user_id': 'wx_new_user',
'username': 'testuser',
'password': 'testpass123',
'force_update': False,
})
self.assertEqual(resp.status_code, 409)

View File

@@ -0,0 +1,132 @@
"""
企业微信快捷登录 API 测试
"""
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from basic_info.models import Employee, Merchant, MerchantTypeEnum
@override_settings(AGENT_ACCESS_KEY='test-key-123')
class WecomLoginAPITestCase(TestCase):
"""测试企业微信快捷登录接口"""
def setUp(self):
self.client = APIClient()
self.url = '/api/v2/wecom/login/'
self.auth_header = 'test-key-123'
# 创建商户
self.merchant = Merchant.objects.create(
name='测试商户',
type=MerchantTypeEnum.STORE,
)
# 创建用户
self.user = User.objects.create_user(
username='testuser',
password='testpass123',
)
# 创建员工并关联用户,设置 wecom_user_id
self.employee = Employee.objects.create(
merchant=self.merchant,
name='测试员工',
sys_user=self.user,
wecom_user_id='wx_user_001',
)
def _post(self, data, auth=None):
"""辅助方法:发送 POST 请求"""
headers = {'HTTP_AUTHORIZATION': auth or self.auth_header}
return self.client.post(self.url, data, format='json', **headers)
# ==================== 认证测试 ====================
def test_missing_authorization_header(self):
"""缺少 Authorization 头应返回 401"""
resp = self.client.post(self.url, {'wecom_user_id': 'wx_user_001'}, format='json')
self.assertEqual(resp.status_code, 401)
def test_invalid_authorization_key(self):
"""错误的 AGENT_ACCESS_KEY 应返回 401"""
resp = self._post({'wecom_user_id': 'wx_user_001'}, auth='wrong-key')
self.assertEqual(resp.status_code, 401)
# ==================== 参数验证测试 ====================
def test_missing_wecom_user_id(self):
"""缺少 wecom_user_id 应返回 400"""
resp = self._post({})
self.assertEqual(resp.status_code, 400)
self.assertIn('wecom_user_id', resp.json())
# ==================== 登录成功测试 ====================
def test_login_success(self):
"""正常登录应返回 200 和 JWT token"""
resp = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn('access', data)
self.assertIn('refresh', data)
self.assertEqual(data['employee_id'], self.employee.id)
self.assertEqual(data['employee_name'], '测试员工')
self.assertEqual(data['username'], 'testuser')
# access token 应该是非空字符串
self.assertTrue(len(data['access']) > 0)
self.assertTrue(len(data['refresh']) > 0)
def test_login_token_is_valid_jwt(self):
"""返回的 token 应该可以用于认证后续请求"""
resp = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp.status_code, 200)
access_token = resp.json()['access']
# 使用 token 访问需要认证的接口
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
# 访问 health check 不需要特殊权限,但需要认证
health_resp = self.client.get('/api/v2/health/')
# health check 应该返回 200不管认证方式
self.assertEqual(health_resp.status_code, 200)
# ==================== 错误场景测试 ====================
def test_unbound_wecom_user_id(self):
"""未绑定的 wecom_user_id 应返回 404"""
resp = self._post({'wecom_user_id': 'wx_unknown_user'})
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.json()['error'], '该企业微信用户未绑定本系统员工')
def test_employee_without_sys_user(self):
"""员工未关联系统用户应返回 404"""
# 创建一个没有 sys_user 的员工
employee_no_user = Employee.objects.create(
merchant=self.merchant,
name='无用户员工',
wecom_user_id='wx_no_user',
)
resp = self._post({'wecom_user_id': 'wx_no_user'})
self.assertEqual(resp.status_code, 404)
self.assertEqual(resp.json()['error'], '该员工未关联系统用户')
def test_inactive_user(self):
"""被禁用的用户应返回 403"""
self.user.is_active = False
self.user.save()
resp = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp.status_code, 403)
self.assertEqual(resp.json()['error'], '该用户已被禁用')
# ==================== 边界测试 ====================
def test_multiple_logins_return_different_tokens(self):
"""多次登录应返回不同的 token每次生成新 token"""
resp1 = self._post({'wecom_user_id': 'wx_user_001'})
resp2 = self._post({'wecom_user_id': 'wx_user_001'})
self.assertEqual(resp1.status_code, 200)
self.assertEqual(resp2.status_code, 200)
# refresh token 每次应该不同
self.assertNotEqual(resp1.json()['refresh'], resp2.json()['refresh'])

View File

@@ -41,6 +41,7 @@ from api_v2.views import (
AgentMesProductionAssignmentListView, AgentMesProductionAssignmentListView,
) )
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
from api_v2.views.wecom import WecomBindingView, WecomLoginView
urlpatterns = [ urlpatterns = [
path('health/', HealthCheckView.as_view(), name='api_v2_health_check'), path('health/', HealthCheckView.as_view(), name='api_v2_health_check'),
@@ -83,4 +84,6 @@ urlpatterns = [
path('mes/production-assignments/<int:assignment_id>/', ProductionAssignmentDetailView.as_view(), name='api_v2_mes_production_assignment_detail'), path('mes/production-assignments/<int:assignment_id>/', ProductionAssignmentDetailView.as_view(), name='api_v2_mes_production_assignment_detail'),
path('shipment-delivery-photos/', ShipmentDeliveryPhotoListCreateView.as_view(), name='api_v2_shipment_delivery_photo_list_create'), path('shipment-delivery-photos/', ShipmentDeliveryPhotoListCreateView.as_view(), name='api_v2_shipment_delivery_photo_list_create'),
path('shipment-delivery-photos/<int:photo_id>/', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'), path('shipment-delivery-photos/<int:photo_id>/', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'),
path('wecom/binduser/', WecomBindingView.as_view(), name='api_v2_wecom_binduser'),
path('wecom/login/', WecomLoginView.as_view(), name='api_v2_wecom_login'),
] ]

View File

@@ -47,6 +47,7 @@ from .ai import (
AgentMesProductionAssignmentListView, AgentMesProductionAssignmentListView,
) )
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
from .wecom import WecomBindingView, WecomLoginView
__all__ = [ __all__ = [
'HealthCheckView', 'HealthCheckView',

212
api_v2/views/wecom.py Normal file
View File

@@ -0,0 +1,212 @@
"""
企业微信 API
供外部企业微信服务端程序调用:
1. 绑定接口:将企业微信 user_id 与本系统 Employee 绑定
2. 登录接口:通过已绑定的 wecom_user_id 快捷获取 JWT token
认证方式AGENT_ACCESS_KEY通过 Authorization 头传入)。
"""
import logging
from django.contrib.auth import authenticate
from rest_framework import serializers, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from api_v2.views.ai import AgentAccessKeyAuthentication
from basic_info.models import Employee
logger = logging.getLogger(__name__)
class WecomBindingRequestSerializer(serializers.Serializer):
wecom_user_id = serializers.CharField(
max_length=128,
help_text='企业微信 OAuth2 返回的 user_id',
)
username = serializers.CharField(
max_length=150,
help_text='本系统用户名',
)
password = serializers.CharField(
max_length=128,
help_text='本系统用户密码',
)
force_update = serializers.BooleanField(
default=False,
required=False,
help_text='如果该 Employee 已绑定其他企业微信用户,是否强制覆盖',
)
class WecomBindingView(APIView):
"""
企业微信用户绑定接口。
POST /api/v2/wecom/binduser/
请求体:
- wecom_user_id: 企业微信用户 ID
- username: 本系统用户名
- password: 本系统用户密码
- force_update: (可选) 是否强制覆盖已有绑定,默认 false
认证Authorization 头传入 AGENT_ACCESS_KEY
"""
authentication_classes = [AgentAccessKeyAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
ser = WecomBindingRequestSerializer(data=request.data)
ser.is_valid(raise_exception=True)
wecom_user_id = ser.validated_data['wecom_user_id']
username = ser.validated_data['username']
password = ser.validated_data['password']
force_update = ser.validated_data.get('force_update', False)
# 1. 验证本系统用户凭据
user = authenticate(username=username, password=password)
if user is None:
return Response(
{'error': '用户名或密码错误'},
status=status.HTTP_401_UNAUTHORIZED,
)
if not user.is_active:
return Response(
{'error': '该用户已被禁用'},
status=status.HTTP_403_FORBIDDEN,
)
# 2. 查找关联的 Employee
employee = Employee.objects.filter(sys_user=user).first()
if employee is None:
return Response(
{'error': '该用户未关联任何员工记录'},
status=status.HTTP_404_NOT_FOUND,
)
# 3. 检查该 wecom_user_id 是否已被其他 Employee 绑定
existing = Employee.objects.filter(wecom_user_id=wecom_user_id).exclude(id=employee.id).first()
if existing is not None:
return Response(
{'error': f'该企业微信用户已绑定到其他员工: {existing.name} (ID: {existing.id})'},
status=status.HTTP_409_CONFLICT,
)
# 4. 检查该 Employee 是否已有不同的 wecom_user_id
if employee.wecom_user_id and employee.wecom_user_id != wecom_user_id:
if not force_update:
return Response(
{
'error': '该员工已绑定其他企业微信用户',
'current_wecom_user_id': employee.wecom_user_id,
'hint': '如需覆盖,请传入 force_update=true',
},
status=status.HTTP_409_CONFLICT,
)
# 5. 执行绑定
employee.wecom_user_id = wecom_user_id
employee.save(update_fields=['wecom_user_id', 'updated_at'])
logger.info(
'WeChat Work binding success: wecom_user_id=%s -> employee=%s (ID: %s)',
wecom_user_id,
employee.name,
employee.id,
)
return Response(
{
'message': '绑定成功',
'employee_id': employee.id,
'employee_name': employee.name,
'wecom_user_id': wecom_user_id,
},
status=status.HTTP_200_OK,
)
class WecomLoginRequestSerializer(serializers.Serializer):
wecom_user_id = serializers.CharField(
max_length=128,
help_text='企业微信 OAuth2 返回的 user_id',
)
class WecomLoginView(APIView):
"""
企业微信快捷登录接口。
POST /api/v2/wecom/login/
通过已绑定的 wecom_user_id 获取本系统的 JWT token
无需再次输入用户名密码。
请求体:
- wecom_user_id: 企业微信用户 ID必须已通过绑定接口完成绑定
认证Authorization 头传入 AGENT_ACCESS_KEY
"""
authentication_classes = [AgentAccessKeyAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
ser = WecomLoginRequestSerializer(data=request.data)
ser.is_valid(raise_exception=True)
wecom_user_id = ser.validated_data['wecom_user_id']
# 1. 通过 wecom_user_id 查找 Employee
employee = Employee.objects.select_related('sys_user').filter(
wecom_user_id=wecom_user_id,
).first()
if employee is None:
return Response(
{'error': '该企业微信用户未绑定本系统员工'},
status=status.HTTP_404_NOT_FOUND,
)
# 2. 检查关联的系统用户
user = employee.sys_user
if user is None:
return Response(
{'error': '该员工未关联系统用户'},
status=status.HTTP_404_NOT_FOUND,
)
if not user.is_active:
return Response(
{'error': '该用户已被禁用'},
status=status.HTTP_403_FORBIDDEN,
)
# 3. 生成 JWT token
refresh = RefreshToken.for_user(user)
logger.info(
'WeChat Work login success: wecom_user_id=%s -> user=%s (employee=%s)',
wecom_user_id,
user.username,
employee.name,
)
return Response(
{
'access': str(refresh.access_token),
'refresh': str(refresh),
'employee_id': employee.id,
'employee_name': employee.name,
'username': user.username,
},
status=status.HTTP_200_OK,
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-05-20 09:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('basic_info', '0026_transportvehicle_and_capacity'),
]
operations = [
migrations.AddField(
model_name='employee',
name='wecom_user_id',
field=models.CharField(blank=True, help_text='企业微信 OAuth2 返回的 user_id', max_length=128, null=True, unique=True, verbose_name='企业微信用户ID'),
),
]

View File

@@ -479,6 +479,14 @@ class Employee(ModelBase):
default=EmployeeStatusEnum.ACTIVE, default=EmployeeStatusEnum.ACTIVE,
verbose_name='员工状态', verbose_name='员工状态',
) )
wecom_user_id = models.CharField(
max_length=128,
unique=True,
null=True,
blank=True,
verbose_name='企业微信用户ID',
help_text='企业微信 OAuth2 返回的 user_id',
)
@property @property
def job_type(self) -> str: def job_type(self) -> str:

View File

@@ -127,6 +127,10 @@ class HaoBuYeFinanceClient:
payload = response.json() payload = response.json()
except ValueError: except ValueError:
payload = {} payload = {}
# 404 且返回了 status=not_found 的 JSON 视为正常业务响应(如客户无退货单),
# 由调用方根据 status 字段决定后续逻辑,而不是在此处抛异常中断整个同步。
if response.status_code == 404 and isinstance(payload, dict) and payload.get('status') == 'not_found':
return payload
message = payload.get('message') or payload.get('error') or f'外部接口异常状态码: {response.status_code}' message = payload.get('message') or payload.get('error') or f'外部接口异常状态码: {response.status_code}'
raise ExternalFinanceSyncError(str(message)) raise ExternalFinanceSyncError(str(message))
try: try:
@@ -775,12 +779,18 @@ def _apply_sale_discount_zk(
progress_callback: Callable[[str], None] | None = None, progress_callback: Callable[[str], None] | None = None,
) -> int: ) -> int:
""" """
从 sale_discount 数据F_Skd 的 XS% 行)中提取 ZkJinE 从 sale_discount 数据F_Skd 的 XS% 行)中提取 ZkJinE 和 YfJinE
更新到对应的 ExternalCustomerStatementOrder.zk_amount。 更新到对应的 ExternalCustomerStatementOrder.zk_amount 和 total_amount
关键修正I_Sale.SfJinE 始终为 0数据源缺陷导致从 I_Sale.JinE 计算的
total_amount 未扣除实付部分。F_Skd XS% 的 YfJinE 已经扣除了实付,是正确的
欠款基数,因此用它覆盖 total_amount。
返回更新的记录数。 返回更新的记录数。
""" """
# 按 BianHaoID 聚合折扣(一个 BianHaoID 可能有多条 F_Skd 记录) # 按 BianHaoID 聚合折扣和应付金额
zk_by_source: dict[str, Decimal] = {} zk_by_source: dict[str, Decimal] = {}
yf_by_source: dict[str, Decimal] = {}
for record in sale_discounts: for record in sale_discounts:
if not isinstance(record, dict): if not isinstance(record, dict):
continue continue
@@ -788,13 +798,31 @@ def _apply_sale_discount_zk(
if not source_id: if not source_id:
continue continue
zk_value = abs(_to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0'))) zk_value = abs(_to_decimal(record.get('ZkJinE'), field_name='ZkJinE', default=Decimal('0')))
# YfJinE 不取 abs():负值代表退款/冲减,应保留原始正负
yf_value = _to_decimal(record.get('YfJinE'), field_name='YfJinE', default=Decimal('0'))
if zk_value: if zk_value:
zk_by_source[source_id] = zk_by_source.get(source_id, Decimal('0')) + zk_value zk_by_source[source_id] = zk_by_source.get(source_id, Decimal('0')) + zk_value
# YfJinE 是每个销售单的正确欠款金额(已扣除实付 SfJinE
# 一个 BianHaoID 通常只有一条 F_Skd XS% 记录,但为安全起见做聚合
# 注意:不过滤零值,因为 YfJinE=0 也是有效数据(表示全额实付)
yf_by_source[source_id] = yf_by_source.get(source_id, Decimal('0')) + yf_value
if not zk_by_source: if not zk_by_source and not yf_by_source:
return 0 return 0
updated_count = 0 updated_count = 0
# 更新 total_amount用 F_Skd XS%.YfJinE 覆盖从 I_Sale.JinE 计算的值
for source_id, yf_total in yf_by_source.items():
updated = business_models.ExternalCustomerStatementOrder.objects.filter(
merchant=merchant,
customer=customer,
category=business_models.ExternalCustomerStatementCategoryEnum.SALE,
external_source_id=source_id,
).exclude(total_amount=yf_total).update(total_amount=yf_total)
updated_count += updated
# 更新 zk_amount
for source_id, zk_total in zk_by_source.items(): for source_id, zk_total in zk_by_source.items():
updated = business_models.ExternalCustomerStatementOrder.objects.filter( updated = business_models.ExternalCustomerStatementOrder.objects.filter(
merchant=merchant, merchant=merchant,
@@ -807,7 +835,7 @@ def _apply_sale_discount_zk(
if updated_count: if updated_count:
_emit_progress( _emit_progress(
progress_callback, progress_callback,
f'销售折扣更新: {updated_count} 条记录的 zk_amount 已从 sale_discount 数据更新', f'销售折扣/应付更新: {updated_count} 条记录已从 sale_discount 数据更新 (total_amount via YfJinE, zk_amount via ZkJinE)',
) )
return updated_count return updated_count

View File

@@ -1,9 +1,12 @@
import logging
from typing import Any, Dict, List from typing import Any, Dict, List
from celery import shared_task from celery import shared_task
from business import services as business_services from business import services as business_services
logger = logging.getLogger(__name__)
@shared_task(bind=True) @shared_task(bind=True)
def create_purchase_order_stock_entries( def create_purchase_order_stock_entries(
@@ -24,6 +27,80 @@ def create_purchase_order_stock_entries(
return payload return payload
@shared_task(bind=True)
def sync_external_customer_finance_scheduled(self) -> Dict[str, Any]:
"""
定时任务:同步指定客户列表的外部财务(对账)数据。
每天凌晨 5 点由 celery beat 触发,逐个客户调用 sync_customer_finance()。
单个客户失败时跳过并记录错误,不影响其他客户。
"""
from django.conf import settings
from basic_info.models import Employee
from business.external_finance_sync import sync_customer_finance
customer_names = getattr(settings, 'FINANCE_SYNC_CUSTOMER_NAMES', [])
operator_id = getattr(settings, 'HAOBUYE_FINANCE_SYNC_OPERATOR_ID', 0)
if not customer_names:
logger.warning('[finance_sync_scheduled] FINANCE_SYNC_CUSTOMER_NAMES 为空,跳过')
return {'status': 'skipped', 'reason': 'empty_customer_list'}
if not operator_id:
logger.error('[finance_sync_scheduled] HAOBUYE_FINANCE_SYNC_OPERATOR_ID 未配置')
return {'status': 'error', 'reason': 'missing_operator_id'}
try:
operator = Employee.objects.select_related('merchant').get(id=operator_id)
except Employee.DoesNotExist:
logger.error('[finance_sync_scheduled] 经办人不存在: id=%s', operator_id)
return {'status': 'error', 'reason': f'operator_not_found:{operator_id}'}
results: List[Dict[str, Any]] = []
errors: List[Dict[str, str]] = []
for customer_name in customer_names:
try:
logger.info('[finance_sync_scheduled] 开始同步客户: %s', customer_name)
result = sync_customer_finance(
customer_name=customer_name,
operator=operator,
allow_create_customer=True,
force_update=True,
)
results.append({
'customer_name': customer_name,
'created_count': result.get('created_count', 0),
'skipped_existing_count': result.get('skipped_existing_count', 0),
'external_business_created_count': result.get('external_business_created_count', 0),
})
logger.info(
'[finance_sync_scheduled] 客户 %s 同步完成: created=%s, skipped=%s',
customer_name,
result.get('created_count', 0),
result.get('skipped_existing_count', 0),
)
except Exception as exc:
logger.exception('[finance_sync_scheduled] 客户 %s 同步失败', customer_name)
errors.append({'customer_name': customer_name, 'error': str(exc)})
summary = {
'status': 'completed',
'total_customers': len(customer_names),
'success_count': len(results),
'error_count': len(errors),
'results': results,
'errors': errors,
}
logger.info(
'[finance_sync_scheduled] 全部完成: success=%s, errors=%s',
len(results),
len(errors),
)
return summary
@shared_task(bind=True) @shared_task(bind=True)
def create_sales_order_stock_entries( def create_sales_order_stock_entries(
self, self,

View File

@@ -0,0 +1,267 @@
# 企业微信用户绑定 API
## 接口说明
供企业微信服务端程序调用,将企业微信 OAuth2 获取的 `user_id` 与本系统的员工Employee绑定。调用方需提供本系统的用户名和密码进行身份验证验证通过后完成绑定。
## 接口信息
- **URL**: `/api/v2/wecom/binduser/`
- **方法**: `POST`
- **Content-Type**: `application/json`
- **认证方式**: 固定密钥,通过 `Authorization` 请求头传入(不使用 Bearer 前缀)
## 认证
请求必须在 `Authorization` 头中携带预分配的访问密钥(由系统管理员提供)。
```
Authorization: your-access-key
```
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| wecom_user_id | string | 是 | 企业微信 OAuth2 返回的 user_id最长 128 字符 |
| username | string | 是 | 本系统用户名 |
| password | string | 是 | 本系统用户密码 |
| force_update | boolean | 否 | 如果该员工已绑定其他企业微信用户,是否强制覆盖。默认 `false` |
## 请求示例
```bash
curl -X POST "https://your-domain/api/v2/wecom/binduser/" \
-H "Authorization: your-access-key" \
-H "Content-Type: application/json" \
-d '{
"wecom_user_id": "zhangsan",
"username": "zhangsan",
"password": "mypassword123"
}'
```
## 响应说明
### 绑定成功200 OK
```json
{
"message": "绑定成功",
"employee_id": 42,
"employee_name": "张三",
"wecom_user_id": "zhangsan"
}
```
### 用户名或密码错误401 Unauthorized
```json
{
"error": "用户名或密码错误"
}
```
### 用户已被禁用403 Forbidden
```json
{
"error": "该用户已被禁用"
}
```
### 用户未关联员工404 Not Found
```json
{
"error": "该用户未关联任何员工记录"
}
```
### 企业微信用户已绑定到其他员工409 Conflict
```json
{
"error": "该企业微信用户已绑定到其他员工: 李四 (ID: 15)"
}
```
### 员工已绑定其他企业微信用户409 Conflict
当该员工已有不同的 `wecom_user_id` 且未传 `force_update=true` 时:
```json
{
"error": "该员工已绑定其他企业微信用户",
"current_wecom_user_id": "old_wecom_id",
"hint": "如需覆盖,请传入 force_update=true"
}
```
### 认证失败401 Unauthorized
Authorization 头缺失或密钥错误:
```json
{
"detail": "AGENT_ACCESS_KEY 无效"
}
```
### 参数验证失败400 Bad Request
```json
{
"wecom_user_id": ["该字段是必填项。"],
"username": ["该字段是必填项。"],
"password": ["该字段是必填项。"]
}
```
## 业务规则
1. **一对一约束**:一个企业微信 user_id 只能绑定一个员工,一个员工只能绑定一个企业微信 user_id。
2. **幂等性**:重复绑定相同的 wecom_user_id 到同一个员工不会报错,返回 200。
3. **冲突处理**
- 如果 `wecom_user_id` 已被其他员工绑定 → 拒绝409
- 如果该员工已绑定不同的 `wecom_user_id``force_update=false` → 拒绝409
- 如果该员工已绑定不同的 `wecom_user_id``force_update=true` → 覆盖绑定
4. **身份验证**:通过 Django 内置认证机制验证用户名和密码,验证通过后通过 `User → Employee` 关系找到对应员工。
## 接入流程
```
企业微信用户 → OAuth2 授权 → 获取 wecom user_id
调用本接口(传入 wecom_user_id + 本系统 username/password
验证凭据 → 查找 Employee → 写入 wecom_user_id
绑定完成,后续可通过 wecom_user_id 识别用户身份
```
## 注意事项
1. 密码通过 HTTPS 传输,请确保生产环境使用 TLS。
2. 该接口不限制调用频率,但建议调用方做适当限流。
3. 绑定成功后,后续业务可通过 `Employee.wecom_user_id` 字段查找对应员工。
---
# 企业微信快捷登录 API
## 接口说明
供企业微信服务端程序调用,通过已绑定的 `wecom_user_id` 快捷获取本系统的 JWT token无需再次输入用户名密码。
前提条件:该 `wecom_user_id` 必须已通过绑定接口(`/api/v2/wecom/binduser/`)完成绑定。
## 接口信息
- **URL**: `/api/v2/wecom/login/`
- **方法**: `POST`
- **Content-Type**: `application/json`
- **认证方式**: 固定密钥,通过 `Authorization` 请求头传入(与绑定接口相同)
## 认证
```
Authorization: your-access-key
```
## 请求参数
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| wecom_user_id | string | 是 | 企业微信 OAuth2 返回的 user_id |
## 请求示例
```bash
curl -X POST "https://your-domain/api/v2/wecom/login/" \
-H "Authorization: your-access-key" \
-H "Content-Type: application/json" \
-d '{
"wecom_user_id": "zhangsan"
}'
```
## 响应说明
### 登录成功200 OK
```json
{
"access": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"employee_id": 42,
"employee_name": "张三",
"username": "zhangsan"
}
```
返回的 `access` token 可直接用于后续业务 API 的 `Authorization: Bearer <token>` 认证。
### 企业微信用户未绑定404 Not Found
```json
{
"error": "该企业微信用户未绑定本系统员工"
}
```
### 员工未关联系统用户404 Not Found
```json
{
"error": "该员工未关联系统用户"
}
```
### 用户已被禁用403 Forbidden
```json
{
"error": "该用户已被禁用"
}
```
### 认证失败401 Unauthorized
Authorization 头缺失或密钥错误:
```json
{
"detail": "AGENT_ACCESS_KEY 无效"
}
```
### 参数验证失败400 Bad Request
```json
{
"wecom_user_id": ["该字段是必填项。"]
}
```
## 业务规则
1. 该接口仅对已完成绑定的 `wecom_user_id` 有效。
2. 每次调用都会生成新的 JWT token 对access + refresh
3. access token 有效期为 7 天(与系统 JWT 配置一致)。
4. 安全边界依赖 `AGENT_ACCESS_KEY`,请妥善保管该密钥。
## 典型使用流程
```
用户首次使用:
企业微信 OAuth2 → 获取 wecom_user_id
→ 调用 /api/v2/wecom/binduser/(传 wecom_user_id + username + password
→ 绑定完成
用户后续登录:
企业微信 OAuth2 → 获取 wecom_user_id
→ 调用 /api/v2/wecom/login/(仅传 wecom_user_id
→ 获取 JWT token
→ 使用 token 访问业务 API
```

View File

@@ -0,0 +1,201 @@
# 客户对账单 API 分页与性能评估
本文档评估当前 `/api/v1/customers/<id>/statements/` 接口的分页能力与性能瓶颈,供前后端共同讨论优化方案。
---
## 1. 当前现状
### 接口行为
- 每次请求返回**该客户的全部对账记录**(销售单 + 退货单 + 收款单 + 外部业务依据)
- 无分页参数,无日期过滤
- 排序固定:`occurred_at → recorded_at → source_id` 倒序
- 每条记录包含 `cumulative_amount`(滚动累计)和 `arrears_amount`(实时欠款)
### 数据规模参考
| 客户 | 销售单 | 退货单 | 收款单 | 总记录数 | 响应体积(估算) |
|------|--------|--------|--------|----------|-----------------|
| 木棉 | ~200 | 1 | ~50 | ~250 | ~100KB |
| 腾飞纺织 | ~1,500 | 9 | ~92 | ~1,600 | ~800KB |
| 张晓鹏 | ~3,100 | 6 | ~219 | ~3,350 | ~2MB |
---
## 2. 为什么当前无法直接分页
核心原因:**滚动累计字段(`cumulative_amount` / `arrears_amount`)依赖前序所有记录的计算结果。**
```
结欠[n] = 结欠[n-1] + 本行应收 - 本行已收
```
要展示第 N 页的 `arrears_amount`,必须先计算前 N-1 页所有记录的累加值。这意味着:
- 不能简单用 DB 的 `OFFSET/LIMIT`
- 不能跳页
- 后端必须从第一条开始逐条计算
---
## 3. 性能瓶颈分析
每次 API 调用的执行路径:
```
1. 4 次 DB 查询sales / returns / receipts / external_statements
└── 每次都 prefetch_related('items__product') 加载全部明细
2. Python 内存中合并 + 排序全部记录
3. 逐条遍历计算 running totals
4. 全量序列化为 JSON含 items 明细数组)
5. HTTP 响应传输
```
对于张晓鹏3350 条记录):
- DB 查询:~200ms4 次查询 + prefetch
- Python 排序 + 计算:~50ms
- JSON 序列化:~300ms
- 网络传输2MB取决于带宽
**`StatementRecordView`(单条查询)更严重**:为了查 1 条记录,构建了完整对账单再过滤。
---
## 4. 优化方案对比
### 方案 A游标分页推荐短期方案
**原理**:前端传 `page_size` + 上一页最后一条的 `cumulative_amount` 作为 cursor后端从 cursor 继续累加。
```
GET /customers/6/statements/?page_size=50
GET /customers/6/statements/?page_size=50&cursor=eyJjdW11bGF0aXZlIjoiMzE4OTEuMDAiLCJsYXN0X2lkIjoxODE0MX0=
```
| 优点 | 缺点 |
|------|------|
| 减少响应体积 | 后端仍需全量查询(但只序列化一页) |
| 前端按需加载 | 不能跳页,只能顺序翻页 |
| 向后兼容(不传参数 = 全量) | 需要前端配合改造 |
**后端改动**:中等。排序 + running total 计算后,只返回 cursor 之后的 N 条。
---
### 方案 B日期范围过滤
**原理**:前端传 `date_from` / `date_to`,后端只查询范围内的记录。
```
GET /customers/6/statements/?date_from=2026-01-01&date_to=2026-05-18
```
| 优点 | 缺点 |
|------|------|
| 实现简单 | running total 仍需从历史第一条开始算 |
| 减少返回数据量 | 或者放弃 running total 的准确性 |
| 前端可做"按年/按月"切换 | |
**后端改动**:低。在 DB 查询加 `occurred_at` 过滤即可。但 `cumulative_amount` 需要决定:
- 选项 1从第一条算到 date_to准确但慢
- 选项 2只在返回范围内累计快但不连续
---
### 方案 C延迟加载 items 明细
**原理**:默认不返回 `items` 数组,前端需要时单独请求。
```
GET /customers/6/statements/ → 不含 items
GET /customers/6/statements/?include_items=true → 含 items当前行为
GET /statements/record/?...&include_items=true → 单条含 items
```
| 优点 | 缺点 |
|------|------|
| 响应体积减少 50%+ | 前端需要额外请求获取明细 |
| 后端改动极小 | 如果前端表格需要展开明细,交互变复杂 |
| 完全向后兼容 | |
**后端改动**:极低。序列化时根据参数决定是否包含 items。
---
### 方案 D预计算快照长期方案
**原理**:同步时预计算每条记录的 `cumulative_amount` / `arrears_amount` 存入 DB查询时直接分页。
| 优点 | 缺点 |
|------|------|
| 真正的 DB 级分页 | 需要新表或新字段 |
| 查询性能最优 | 数据一致性维护复杂(任何单据变动需重算) |
| 支持跳页 | 开发成本高 |
**后端改动**:高。需要设计快照表 + 触发重算机制。
---
### 方案 E响应缓存
**原理**:对账单结果缓存 N 秒(或按数据版本号缓存),重复请求直接返回。
| 优点 | 缺点 |
|------|------|
| 零前端改动 | 数据有延迟(缓存过期前看不到最新) |
| 后端改动极小 | 大客户首次请求仍然慢 |
| 对高频刷新场景效果显著 | |
---
## 5. 推荐实施路径
```
第一步(立即可做):方案 C — 默认不返回 items减少 50%+ 响应体积
第二步(短期):方案 B — 加日期范围过滤,前端做"按月/按年"切换
第三步(中期):方案 A — 游标分页,前端改为滚动加载
第四步(按需):方案 E — 缓存,应对高频刷新
```
---
## 6. 需要前端确认的问题
1. **对账单表格是否需要一次展示全部记录?** 还是可以接受分页/滚动加载?
2. **items 明细是否默认展示?** 还是用户点击展开时才加载?
3. **是否需要"按月/按年"切换?** 如果需要running total 是否可以只在当前范围内累计?
4. **`cumulative_amount` / `arrears_amount` 是否是必须字段?** 如果前端不使用这两个字段,分页就变得简单很多。
5. **导出 Excel 功能是否需要全量数据?** 如果需要,导出可以走单独的异步接口。
---
## 7. 当前接口响应结构(供参考)
```json
{
"counterparty": 6,
"counterparty_name": "张晓鹏",
"records": [
{
"source_type": "external_sales_order",
"source_label": "外部销售单",
"source_id": 18141,
"occurred_at": "2026-05-18",
"recorded_at": "2026-05-18T21:45:03Z",
"status": 2,
"status_label": "已同步",
"positive_amount": "2436.00",
"negative_amount": "0.00",
"cumulative_amount": "31891.00",
"current_balance": "780454.00",
"arrears_amount": "748563.00",
"remarks": "...",
"items": [ ... ]
}
],
"summary": {
"positive_total": "26981509.80",
"negative_total": "26201055.80"
}
}
```

View File

@@ -98,7 +98,16 @@ AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='')
HAOBUYE_API_BASE_URL = env('HAOBUYE_API_BASE_URL', default='http://43.139.183.222:18080') 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_AUTHORIZATION = env('HAOBUYE_API_AUTHORIZATION', default='your-fixed-authorization-secret')
HAOBUYE_API_TIMEOUT_SECONDS = env.float('HAOBUYE_API_TIMEOUT_SECONDS', default=30.0) 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) HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', default=6)
# 定时财务同步客户列表(临时需求,直接写死不走 env
FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [
'曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎',
'罗标', '永琪服饰', '罗兵', '杜辉', '李群', '超麦汇大洋',
'展兴', '王杰敏', '锐木', '何玄', '周刚(周总)', '彤彤服饰',
'杰恩', '来发裁床', '刘静贤', '金源', '达达-金腾达qs', '周乔峰',
'杨锐辉',
]
# CORS 配置 # CORS 配置
@@ -560,96 +569,101 @@ CELERY_TIMEZONE = TIME_ZONE
from celery.schedules import crontab from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
'daily_database_backup': { # 'daily_database_backup': {
'task': 'api_v1.tasks.backup_database', # 'task': 'api_v1.tasks.backup_database',
'schedule': crontab(hour=3, minute=0), # 'schedule': crontab(hour=3, minute=0),
'kwargs': { # 'kwargs': {
'output_dir': str(BASE_DIR / 'data-bak'), # 'output_dir': str(BASE_DIR / 'data-bak'),
'filename_prefix': 'db-backup', # 'filename_prefix': 'db-backup',
}, # },
}, # },
'daily_settlement': { # 'daily_settlement': {
'task': 'settlement.tasks.run_daily_settlement', # 'task': 'settlement.tasks.run_daily_settlement',
'schedule': crontab( # 'schedule': crontab(
hour=SETTLEMENT_RUN_HOUR, # hour=SETTLEMENT_RUN_HOUR,
minute=SETTLEMENT_RUN_MINUTE # minute=SETTLEMENT_RUN_MINUTE
), # ),
}, # },
'daily_plate_order_tiia_image_upload': { # 'daily_plate_order_tiia_image_upload': {
'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia', # 'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia',
'schedule': crontab(hour=3, minute=0), # 'schedule': crontab(hour=3, minute=0),
}, # },
'daily_mdy_plate_order_staging_tiia_image_upload': { # 'daily_mdy_plate_order_staging_tiia_image_upload': {
'task': 'api_v1.tasks.upload_mdy_plate_order_staging_images_to_tencent_tiia', # 'task': 'api_v1.tasks.upload_mdy_plate_order_staging_images_to_tencent_tiia',
'schedule': crontab(hour=3, minute=20), # 'schedule': crontab(hour=3, minute=20),
'kwargs': { # 'kwargs': {
'batch_size': 500, # 'batch_size': 500,
'dry_run': False, # 'dry_run': False,
}, # },
}, # },
'mdy_product_sync': { # 'mdy_product_sync': {
'task': 'api_v1.tasks.sync_mdy_products', # 'task': 'api_v1.tasks.sync_mdy_products',
'schedule': crontab(minute='*/10'), # 'schedule': crontab(minute='*/10'),
'kwargs': { # 'kwargs': {
'page_size': MDY_SYNC_PAGE_SIZE, # 'page_size': MDY_SYNC_PAGE_SIZE,
'max_pages': MDY_SYNC_MAX_PAGES, # 'max_pages': MDY_SYNC_MAX_PAGES,
'max_records': MDY_SYNC_MAX_RECORDS, # 'max_records': MDY_SYNC_MAX_RECORDS,
}, # },
}, # },
'mdy_customer_sync': { # 'mdy_customer_sync': {
'task': 'api_v1.tasks.sync_mdy_customers', # 'task': 'api_v1.tasks.sync_mdy_customers',
'schedule': crontab(minute='*/5'), # 'schedule': crontab(minute='*/5'),
'kwargs': { # 'kwargs': {
'page_size': MDY_SYNC_PAGE_SIZE, # 'page_size': MDY_SYNC_PAGE_SIZE,
'max_pages': MDY_SYNC_MAX_PAGES, # 'max_pages': MDY_SYNC_MAX_PAGES,
'max_records': MDY_SYNC_MAX_RECORDS, # 'max_records': MDY_SYNC_MAX_RECORDS,
}, # },
}, # },
'printing_external_records_sync': { # 'printing_external_records_sync': {
'task': 'api_v1.tasks.sync_external_printing_records', # 'task': 'api_v1.tasks.sync_external_printing_records',
'schedule': crontab(minute='*/5'), # 'schedule': crontab(minute='*/5'),
'kwargs': { # 'kwargs': {
'limit': 100, # 'limit': 100,
}, # },
}, # },
'notify_unreplied_missions_every_minute': { # 'notify_unreplied_missions_every_minute': {
'task': 'notifier.tasks.notify_unreplied_missions_task', # 'task': 'notifier.tasks.notify_unreplied_missions_task',
'schedule': crontab(minute='*'), # 'schedule': crontab(minute='*'),
'kwargs': { # 'kwargs': {
'limit': 100, # 'limit': 100,
}, # },
}, # },
'backfill_external_product_images_hourly': { # 'backfill_external_product_images_hourly': {
'task': 'api_v1.tasks.backfill_external_product_images', # 'task': 'api_v1.tasks.backfill_external_product_images',
'schedule': crontab(minute=17), # 'schedule': crontab(minute=17),
'kwargs': { # 'kwargs': {
'limit': 500, # 'limit': 500,
'dry_run': False, # 'dry_run': False,
}, # },
}, # },
'retry_external_printing_sync_failures_every_120_minutes': { # 'retry_external_printing_sync_failures_every_120_minutes': {
'task': 'api_v1.tasks.retry_external_printing_sync_failures', # 'task': 'api_v1.tasks.retry_external_printing_sync_failures',
'schedule': crontab(minute='0', hour='*/2'), # 'schedule': crontab(minute='0', hour='*/2'),
'kwargs': { # 'kwargs': {
'limit': 100, # 'limit': 100,
}, # },
# },
# 外部财务同步:每天凌晨 5 点同步指定客户列表的对账数据
'sync_external_customer_finance_daily': {
'task': 'business.tasks.sync_external_customer_finance_scheduled',
'schedule': crontab(hour=5, minute=0),
}, },
# 明道云开版:同步到“暂存表” # 明道云开版:同步到“暂存表”
# - 仅在 02:00-08:00 时间窗内持续运行(每 N 分钟触发一次) # - 仅在 02:00-08:00 时间窗内持续运行(每 N 分钟触发一次)
# - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS # - 通过 request_interval_seconds 控制单次任务对明道云 API 的请求节奏,避免超 QPS
'mdy_plate_order_staging_sync': { # 'mdy_plate_order_staging_sync': {
'task': 'api_v1.tasks.sync_mdy_plate_orders', # 'task': 'api_v1.tasks.sync_mdy_plate_orders',
# 02:00 - 07:59不包含 08:xx # # 02:00 - 07:59不包含 08:xx
'schedule': crontab(minute='*/2', hour='2-7'), # 'schedule': crontab(minute='*/2', hour='2-7'),
'kwargs': { # 'kwargs': {
'page_size': max(1, int(MDY_SYNC_PAGE_SIZE)), # 'page_size': max(1, int(MDY_SYNC_PAGE_SIZE)),
'max_pages': max(1, int(MDY_SYNC_MAX_PAGES)), # 'max_pages': max(1, int(MDY_SYNC_MAX_PAGES)),
'max_records': max(1, int(MDY_SYNC_MAX_RECORDS)), # 'max_records': max(1, int(MDY_SYNC_MAX_RECORDS)),
'with_related': True, # 'with_related': True,
'max_related_per_type': 5, # 'max_related_per_type': 5,
'request_interval_seconds': float(MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS), # 'request_interval_seconds': float(MDY_PLATE_ORDER_SYNC_REQUEST_INTERVAL_SECONDS),
}, # },
}, # },
} }
if MDY_ISOLATED_MERCHANT_ID and MDY_ISOLATED_MERCHANT_ID > 0: if MDY_ISOLATED_MERCHANT_ID and MDY_ISOLATED_MERCHANT_ID > 0: