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,