1
0
forked from erp-dev/erp

fix: appversions

This commit is contained in:
2026-07-11 00:05:05 +08:00
parent 48e4782e1e
commit e91d06e4b6
26 changed files with 2582 additions and 19 deletions

View File

@@ -1,14 +1,27 @@
"""日结模块统计服务函数"""
import logging
from datetime import datetime, date
from datetime import datetime, date, time, timedelta
from decimal import Decimal, InvalidOperation
from django.db.models import Q, Sum, Case, When, Value, CharField, IntegerField
from django.db.models import Prefetch
from django.db.models import Q, Sum, Case, When, Value, CharField, IntegerField, Count
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Concat
from django.utils.dateparse import parse_datetime
from django.utils import timezone
from printing.models import PlateOrder
from stateflow.models import StateFlowRecord, StateLogParameterRecord
logger = logging.getLogger(__name__)
DEFAULT_DESIGNER_SUMMARY_STATE_NAMES = ("画图完成",)
DEFAULT_DESIGNER_TASK_STATE_NAME_SUFFIX = "完成"
DEFAULT_DESIGNER_PARAM_KEY = "设计师名称"
DEFAULT_DESIGNER_TASK_QUANTITY_PARAM_KEY = "完成数量"
DEFAULT_DESIGNER_TASK_TIME_PARAM_KEY = "完成时间"
DEFAULT_EMPTY_TASK_QUANTITY = Decimal("1")
def get_plate_order_summary_by_customer(
merchant_id: int,
@@ -43,6 +56,427 @@ def get_plate_order_summary_by_customer(
return _format_plate_order_summary(aggregated_data)
def get_plate_order_summary_by_designer(
merchant_id: int,
settlement_date: date,
user=None,
state_names: list[str] | tuple[str, ...] | None = None,
designer_param_key: str = DEFAULT_DESIGNER_PARAM_KEY,
) -> list[dict]:
"""
获取按设计师分组的开版订单统计。
设计师来源于 stateflow 参数记录,默认只统计“画图完成”节点下的
``parameters["设计师名称"]``。同一设计师在同一开版单同一类型下出现多次只计 1 单。
"""
logger.info(
f'[settlement.services] 获取开版订单设计师统计: '
f'merchant_id={merchant_id}, date={settlement_date}, states={state_names}'
)
settlement_date = _validate_and_normalize_summary_args(
merchant_id=merchant_id,
settlement_date=settlement_date,
)
normalized_state_names = _normalize_designer_summary_state_names(state_names)
if not designer_param_key or not str(designer_param_key).strip():
raise ValueError('designer_param_key 不能为空')
month_start_dt, next_day_dt, today_start_dt = _get_summary_datetime_ranges(
settlement_date
)
queryset = _get_designer_plate_order_queryset(
merchant_id=merchant_id,
user=user,
state_names=normalized_state_names,
designer_param_key=designer_param_key,
month_start_dt=month_start_dt,
next_day_dt=next_day_dt,
)
aggregated_data = _aggregate_plate_orders_by_designer_and_type(
queryset=queryset,
today_start_dt=today_start_dt,
next_day_dt=next_day_dt,
)
return _format_plate_order_designer_summary(aggregated_data)
def get_designer_workflow_task_summary(
merchant_id: int,
settlement_date: date,
user=None,
state_names: list[str] | tuple[str, ...] | None = None,
designer_names: list[str] | tuple[str, ...] | None = None,
designer_param_key: str = DEFAULT_DESIGNER_PARAM_KEY,
quantity_param_key: str = DEFAULT_DESIGNER_TASK_QUANTITY_PARAM_KEY,
time_param_key: str = DEFAULT_DESIGNER_TASK_TIME_PARAM_KEY,
) -> list[dict]:
"""
获取设计师工序任务量统计。
统计单位是未撤销的 StateFlowRecord。每条状态流转记录最多计入一次
即使其下存在多条 StateLogParameterRecord。
"""
logger.info(
f'[settlement.services] 获取设计师工序任务量统计: '
f'merchant_id={merchant_id}, date={settlement_date}, states={state_names}'
)
settlement_date = _validate_and_normalize_summary_args(
merchant_id=merchant_id,
settlement_date=settlement_date,
)
normalized_state_names = _normalize_optional_state_names(state_names)
normalized_designer_names = _normalize_optional_names(designer_names)
for key, label in [
(designer_param_key, 'designer_param_key'),
(quantity_param_key, 'quantity_param_key'),
(time_param_key, 'time_param_key'),
]:
if not key or not str(key).strip():
raise ValueError(f'{label} 不能为空')
month_start_dt, next_day_dt, today_start_dt = _get_summary_datetime_ranges(
settlement_date
)
queryset = _get_designer_workflow_task_queryset(
merchant_id=merchant_id,
user=user,
state_names=normalized_state_names,
designer_param_key=designer_param_key,
)
summary = _aggregate_designer_workflow_tasks(
queryset=queryset,
month_start_dt=month_start_dt,
today_start_dt=today_start_dt,
next_day_dt=next_day_dt,
designer_names=normalized_designer_names,
designer_param_key=designer_param_key,
quantity_param_key=quantity_param_key,
time_param_key=time_param_key,
)
return _format_designer_workflow_task_summary(summary)
def _normalize_optional_names(
names: list[str] | tuple[str, ...] | None,
) -> tuple[str, ...] | None:
if names is None:
return None
normalized = tuple(name.strip() for name in names if isinstance(name, str) and name.strip())
if not normalized:
raise ValueError('designer_names 不能为空')
return normalized
def _normalize_optional_state_names(
state_names: list[str] | tuple[str, ...] | None,
) -> tuple[str, ...] | None:
if state_names is None:
return None
normalized = tuple(
name.strip() for name in state_names if isinstance(name, str) and name.strip()
)
if not normalized:
raise ValueError('state_names 不能为空')
return normalized
def _normalize_designer_summary_state_names(
state_names: list[str] | tuple[str, ...] | None,
) -> tuple[str, ...]:
"""标准化设计师统计节点名。"""
if state_names is None:
return DEFAULT_DESIGNER_SUMMARY_STATE_NAMES
normalized = tuple(
name.strip() for name in state_names if isinstance(name, str) and name.strip()
)
if not normalized:
raise ValueError('state_names 不能为空')
return normalized
def _get_summary_datetime_ranges(settlement_date: date):
"""返回月初、次日、当天起始的当前时区 aware datetime。"""
current_tz = timezone.get_current_timezone()
month_start = settlement_date.replace(day=1)
month_start_dt = timezone.make_aware(
datetime.combine(month_start, time.min), current_tz
)
today_start_dt = timezone.make_aware(
datetime.combine(settlement_date, time.min), current_tz
)
next_day_dt = timezone.make_aware(
datetime.combine(settlement_date + timedelta(days=1), time.min), current_tz
)
return month_start_dt, next_day_dt, today_start_dt
def _get_designer_workflow_task_queryset(
*,
merchant_id: int,
user=None,
state_names: tuple[str, ...] | None,
designer_param_key: str,
):
queryset = StateFlowRecord.objects.filter(
is_cancelled=False,
business_object__plate_order__merchant_id=merchant_id,
business_object__plate_order__plate_type__isnull=False,
business_object__plate_order__production_method__isnull=False,
parameter_records__parameters__has_key=designer_param_key,
).select_related(
'state',
'business_object__plate_order',
'business_object__plate_order__customer',
).prefetch_related(
Prefetch(
'parameter_records',
queryset=StateLogParameterRecord.objects.order_by('created_at', 'id'),
to_attr='_prefetched_parameter_records',
)
).distinct()
if state_names is None:
queryset = queryset.filter(state__name__endswith=DEFAULT_DESIGNER_TASK_STATE_NAME_SUFFIX)
else:
queryset = queryset.filter(state__name__in=state_names)
if user and not user.is_superuser:
emp = getattr(user, 'employee', None)
if emp:
queryset = queryset.filter(
Q(business_object__plate_order__customer__created_by=emp) |
Q(business_object__plate_order__customer__visible_employees=emp)
).distinct()
return queryset.order_by('id')
def _aggregate_designer_workflow_tasks(
*,
queryset,
month_start_dt: datetime,
today_start_dt: datetime,
next_day_dt: datetime,
designer_names: tuple[str, ...] | None,
designer_param_key: str,
quantity_param_key: str,
time_param_key: str,
) -> dict[tuple[str, str, str], dict[str, Decimal]]:
summary: dict[tuple[str, str, str], dict[str, Decimal]] = {}
designer_name_filter = set(designer_names) if designer_names is not None else None
for state_log in queryset:
parameters = _merge_state_log_parameters(state_log)
designer_name = str(parameters.get(designer_param_key) or '').strip()
if not designer_name:
continue
if designer_name_filter is not None and designer_name not in designer_name_filter:
continue
effective_time = _resolve_task_effective_time(
parameters.get(time_param_key),
fallback=state_log.completed_at,
)
if effective_time is None or effective_time < month_start_dt or effective_time >= next_day_dt:
continue
plate_order = state_log.business_object.plate_order
task_quantity = _parse_task_quantity(parameters.get(quantity_param_key))
key = (
designer_name,
state_log.state.name,
f'{plate_order.plate_type}-{plate_order.production_method}',
)
if key not in summary:
summary[key] = {'today': Decimal('0'), 'current_month': Decimal('0')}
summary[key]['current_month'] += task_quantity
if today_start_dt <= effective_time < next_day_dt:
summary[key]['today'] += task_quantity
return summary
def _merge_state_log_parameters(state_log: StateFlowRecord) -> dict:
records = getattr(state_log, '_prefetched_parameter_records', None)
if records is None:
records = state_log.parameter_records.all().order_by('created_at', 'id')
merged = {}
for record in records:
if isinstance(record.parameters, dict):
merged.update(record.parameters)
return merged
def _parse_task_quantity(value) -> Decimal:
try:
quantity = Decimal(str(value).strip())
except (InvalidOperation, ValueError, TypeError, AttributeError):
return DEFAULT_EMPTY_TASK_QUANTITY
if quantity > 0:
return quantity
return DEFAULT_EMPTY_TASK_QUANTITY
def _resolve_task_effective_time(value, *, fallback):
parsed = None
if isinstance(value, datetime):
parsed = value
elif isinstance(value, str) and value.strip():
raw_value = value.strip()
parsed = parse_datetime(raw_value)
if parsed is None:
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'):
try:
parsed = datetime.strptime(raw_value, fmt)
break
except ValueError:
continue
effective = parsed or fallback
if effective is None:
return None
if timezone.is_naive(effective):
effective = timezone.make_aware(effective, timezone.get_current_timezone())
return effective.astimezone(timezone.get_current_timezone())
def _format_decimal_quantity(value: Decimal):
if value == value.to_integral_value():
return int(value)
return str(value.normalize())
def _format_designer_workflow_task_summary(
summary: dict[tuple[str, str, str], dict[str, Decimal]],
) -> list[dict]:
result: dict[str, dict] = {}
for (designer_name, state_name, type_name), counts in sorted(summary.items()):
if designer_name not in result:
result[designer_name] = {
'designer_name': designer_name,
'task_count': [],
'today': Decimal('0'),
'current_month': Decimal('0'),
}
result[designer_name]['task_count'].append({
'state_name': state_name,
'type': type_name,
'today': _format_decimal_quantity(counts['today']),
'current_month': _format_decimal_quantity(counts['current_month']),
})
result[designer_name]['today'] += counts['today']
result[designer_name]['current_month'] += counts['current_month']
formatted = []
for item in result.values():
item['today'] = _format_decimal_quantity(item['today'])
item['current_month'] = _format_decimal_quantity(item['current_month'])
formatted.append(item)
return formatted
def _get_designer_plate_order_queryset(
*,
merchant_id: int,
user=None,
state_names: tuple[str, ...],
designer_param_key: str,
month_start_dt: datetime,
next_day_dt: datetime,
):
"""获取设计师统计的 stateflow 参数记录基础查询集。"""
queryset = StateLogParameterRecord.objects.filter(
parameters__has_key=designer_param_key,
state_log__is_cancelled=False,
state_log__state__name__in=state_names,
state_log__business_object__plate_order__merchant_id=merchant_id,
state_log__business_object__plate_order__plate_type__isnull=False,
state_log__business_object__plate_order__production_method__isnull=False,
state_log__business_object__plate_order__plate_date__gte=month_start_dt,
state_log__business_object__plate_order__plate_date__lt=next_day_dt,
).annotate(
designer_name=KeyTextTransform(designer_param_key, 'parameters'),
type=Concat(
'state_log__business_object__plate_order__plate_type',
Value('-'),
'state_log__business_object__plate_order__production_method',
output_field=CharField(),
),
).exclude(
designer_name__isnull=True,
).exclude(
designer_name='',
)
if user and not user.is_superuser:
emp = getattr(user, 'employee', None)
if emp:
queryset = queryset.filter(
Q(state_log__business_object__plate_order__customer__created_by=emp) |
Q(state_log__business_object__plate_order__customer__visible_employees=emp)
).distinct()
return queryset
def _aggregate_plate_orders_by_designer_and_type(
*,
queryset,
today_start_dt: datetime,
next_day_dt: datetime,
):
"""按设计师和类型聚合开版订单数量。"""
plate_order_path = 'state_log__business_object__plate_order'
return queryset.values(
'designer_name',
'type',
).annotate(
today=Count(
plate_order_path,
filter=Q(
state_log__business_object__plate_order__plate_date__gte=today_start_dt,
state_log__business_object__plate_order__plate_date__lt=next_day_dt,
),
distinct=True,
),
current_month=Count(
plate_order_path,
distinct=True,
),
).order_by('designer_name', 'type')
def _format_plate_order_designer_summary(aggregated_data):
"""格式化设计师聚合结果为 API 返回格式。"""
result = {}
for item in aggregated_data:
designer_name = item['designer_name']
if designer_name not in result:
result[designer_name] = {
'designer_name': designer_name,
'plate_order_count': [],
}
result[designer_name]['plate_order_count'].append({
'type': item['type'],
'today': item['today'],
'current_month': item['current_month'],
})
for designer_name in result:
result[designer_name]['plate_order_count'] = _filter_zero_data(
result[designer_name]['plate_order_count']
)
return [v for v in result.values() if v['plate_order_count']]
def _validate_and_normalize_summary_args(
merchant_id: int,
settlement_date: date,

View File

@@ -8,6 +8,7 @@ from django.utils import timezone
from basic_info import models as basic_models
from printing import models as printing_models
from settlement import services
from stateflow import models as stateflow_models
User = get_user_model()
@@ -508,3 +509,308 @@ class GetPlateOrderSummaryByCustomerTestCase(SettlementServiceTestCase):
settlement_date='2026-02-08',
user=self.user
)
class GetPlateOrderSummaryByDesignerTestCase(SettlementServiceTestCase):
def setUp(self):
super().setUp()
self.process = stateflow_models.Process.objects.create(name='开版流程')
self.drawing_done = stateflow_models.State.objects.create(name='画图完成')
self.color_done = stateflow_models.State.objects.create(name='调色完成')
def _create_plate_order(self, *, customer=None, plate_date=None, plate_type='首版', production_method='定位'):
plate_order = printing_models.PlateOrder.objects.create(
merchant=self.merchant,
customer=customer or self.customer1,
plate_type=plate_type,
production_method=production_method,
plate_date=timezone.make_aware(datetime.combine(plate_date or self.settlement_date, datetime.min.time())),
)
business_object = stateflow_models.BusinessObject.objects.create(
name=f'PlateOrder-{plate_order.id}',
process=self.process,
description='',
)
plate_order.business_object = business_object
plate_order.save(update_fields=['business_object'])
return plate_order
def _add_designer_param(self, plate_order, *, designer_name, state=None, cancelled=False):
state_log = stateflow_models.StateFlowRecord.objects.create(
business_object=plate_order.business_object,
state=state or self.drawing_done,
completed_by=self.user,
is_cancelled=cancelled,
)
return stateflow_models.StateLogParameterRecord.objects.create(
state_log=state_log,
parameters={'设计师名称': designer_name},
)
def test_returns_summary_for_drawing_done_designer(self):
plate_order = self._create_plate_order()
self._add_designer_param(plate_order, designer_name='设计师A')
result = services.get_plate_order_summary_by_designer(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['designer_name'], '设计师A')
self.assertEqual(result[0]['plate_order_count'][0]['type'], '首版-定位')
self.assertEqual(result[0]['plate_order_count'][0]['today'], 1)
self.assertEqual(result[0]['plate_order_count'][0]['current_month'], 1)
def test_defaults_to_drawing_done_only(self):
plate_order = self._create_plate_order()
self._add_designer_param(plate_order, designer_name='设计师A', state=self.color_done)
result = services.get_plate_order_summary_by_designer(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(result, [])
class GetDesignerWorkflowTaskSummaryTestCase(SettlementServiceTestCase):
def setUp(self):
super().setUp()
self.process = stateflow_models.Process.objects.create(name='开版流程')
self.drawing_done = stateflow_models.State.objects.create(name='画图完成')
self.color_done = stateflow_models.State.objects.create(name='调色完成')
self.drawing_in_progress = stateflow_models.State.objects.create(name='画图中')
def _create_plate_order(self, *, customer=None, plate_type='首版', production_method='定位'):
plate_order = printing_models.PlateOrder.objects.create(
merchant=self.merchant,
customer=customer or self.customer1,
plate_type=plate_type,
production_method=production_method,
plate_date=timezone.make_aware(datetime.combine(self.settlement_date, datetime.min.time())),
)
business_object = stateflow_models.BusinessObject.objects.create(
name=f'PlateOrder-{plate_order.id}',
process=self.process,
description='',
)
plate_order.business_object = business_object
plate_order.save(update_fields=['business_object'])
return plate_order
def _create_state_log(self, plate_order, *, state=None, cancelled=False):
return stateflow_models.StateFlowRecord.objects.create(
business_object=plate_order.business_object,
state=state or self.drawing_done,
completed_by=self.user,
is_cancelled=cancelled,
)
def _add_params(self, state_log, params):
return stateflow_models.StateLogParameterRecord.objects.create(
state_log=state_log,
parameters=params,
)
def _add_designer_param(self, plate_order, *, designer_name, state=None, cancelled=False):
state_log = self._create_state_log(
plate_order,
state=state or self.drawing_done,
cancelled=cancelled,
)
return self._add_params(state_log, {
'设计师名称': designer_name,
'完成时间': '2026-02-08 10:00:00',
})
def test_sums_task_quantity_by_state_and_type(self):
first = self._create_plate_order()
second = self._create_plate_order()
first_log = self._create_state_log(first, state=self.drawing_done)
second_log = self._create_state_log(second, state=self.color_done)
self._add_params(first_log, {
'设计师名称': '左威',
'完成数量': '3',
'完成时间': '2026-02-08 10:00:00',
})
self._add_params(second_log, {
'设计师名称': '左威',
'完成数量': '2',
'完成时间': '2026-02-08 11:00:00',
})
result = services.get_designer_workflow_task_summary(
self.merchant.id,
self.settlement_date,
self.user,
state_names=['画图完成', '调色完成'],
)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['designer_name'], '左威')
self.assertEqual(result[0]['today'], 5)
self.assertEqual(result[0]['current_month'], 5)
by_state = {item['state_name']: item for item in result[0]['task_count']}
self.assertEqual(by_state['画图完成']['today'], 3)
self.assertEqual(by_state['调色完成']['today'], 2)
def test_default_state_filter_includes_only_done_suffix(self):
for state, quantity in [
(self.drawing_done, '3'),
(self.color_done, '2'),
(self.drawing_in_progress, '10'),
]:
plate_order = self._create_plate_order()
state_log = self._create_state_log(plate_order, state=state)
self._add_params(state_log, {
'设计师名称': '左威',
'完成数量': quantity,
'完成时间': '2026-02-08 10:00:00',
})
result = services.get_designer_workflow_task_summary(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(result[0]['today'], 5)
states = {item['state_name'] for item in result[0]['task_count']}
self.assertEqual(states, {'画图完成', '调色完成'})
def test_invalid_quantity_defaults_to_one_and_does_not_dedupe_plate_order(self):
plate_order = self._create_plate_order()
values = [None, '', '0', '-2', 'bad']
for index, value in enumerate(values):
state_log = self._create_state_log(plate_order)
self._add_params(state_log, {
'设计师名称': '左威',
'完成数量': value,
'完成时间': f'2026-02-08 10:0{index}:00',
})
result = services.get_designer_workflow_task_summary(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(result[0]['today'], 5)
self.assertEqual(result[0]['current_month'], 5)
def test_multiple_parameter_records_for_one_state_log_count_once(self):
plate_order = self._create_plate_order()
state_log = self._create_state_log(plate_order)
self._add_params(state_log, {'设计师名称': '左威'})
self._add_params(state_log, {
'完成数量': '4',
'完成时间': '2026-02-08 10:00:00',
})
result = services.get_designer_workflow_task_summary(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(result[0]['today'], 4)
self.assertEqual(result[0]['current_month'], 4)
def test_uses_completed_at_when_time_param_missing(self):
plate_order = self._create_plate_order()
state_log = self._create_state_log(plate_order)
completed_at = timezone.make_aware(datetime(2026, 2, 8, 12, 0, 0))
stateflow_models.StateFlowRecord.objects.filter(id=state_log.id).update(completed_at=completed_at)
state_log.refresh_from_db()
self._add_params(state_log, {
'设计师名称': '左威',
'完成数量': '2',
})
result = services.get_designer_workflow_task_summary(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(result[0]['today'], 2)
def test_filters_cancelled_and_designer_names(self):
first = self._create_plate_order()
second = self._create_plate_order()
cancelled_log = self._create_state_log(first, cancelled=True)
kept_log = self._create_state_log(second)
self._add_params(cancelled_log, {
'设计师名称': '左威',
'完成数量': '10',
'完成时间': '2026-02-08 10:00:00',
})
self._add_params(kept_log, {
'设计师名称': '王五',
'完成数量': '3',
'完成时间': '2026-02-08 10:00:00',
})
result = services.get_designer_workflow_task_summary(
self.merchant.id,
self.settlement_date,
self.user,
designer_names=['王五'],
)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['designer_name'], '王五')
self.assertEqual(result[0]['today'], 3)
def test_accepts_custom_state_names(self):
plate_order = self._create_plate_order()
self._add_designer_param(plate_order, designer_name='设计师A', state=self.color_done)
result = services.get_plate_order_summary_by_designer(
self.merchant.id,
self.settlement_date,
self.user,
state_names=['画图完成', '调色完成'],
)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['designer_name'], '设计师A')
def test_distincts_same_order_same_designer(self):
plate_order = self._create_plate_order()
self._add_designer_param(plate_order, designer_name='设计师A')
self._add_designer_param(plate_order, designer_name='设计师A')
result = services.get_plate_order_summary_by_designer(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(result[0]['plate_order_count'][0]['today'], 1)
self.assertEqual(result[0]['plate_order_count'][0]['current_month'], 1)
def test_applies_customer_visibility(self):
other_employee = basic_models.Employee.objects.create(
sys_user=User.objects.create_user('designer-other', 'pass123'),
merchant=self.merchant,
name='其他员工'
)
invisible_customer = basic_models.Customer.objects.create(
merchant=self.merchant,
name='不可见客户',
created_by=other_employee,
)
plate_order = self._create_plate_order(customer=invisible_customer)
self._add_designer_param(plate_order, designer_name='设计师A')
result = services.get_plate_order_summary_by_designer(
self.merchant.id,
self.settlement_date,
self.user,
)
self.assertEqual(result, [])