ioe/inventory/forms/report_forms.py
2025-04-27 14:50:32 +08:00

501 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from django import forms
from django.utils import timezone
from datetime import timedelta, datetime, date
from inventory.models import Category, Store
class DateRangeForm(forms.Form):
"""通用日期范围表单,用于所有报表"""
PERIOD_CHOICES = [
('day', '按日'),
('week', '按周'),
('month', '按月'),
('quarter', '按季度'),
('year', '按年'),
('hour', '按小时'), # 新增按小时统计选项
('minute', '按分钟'), # 新增按分钟统计选项,适用于实时监控
]
CACHE_PRESETS = [
(5, '5分钟'), # 新增更短的缓存时间
(15, '15分钟'),
(30, '30分钟'),
(60, '1小时'),
(180, '3小时'),
(360, '6小时'),
(720, '12小时'),
(1440, '24小时'),
(2880, '2天'), # 新增更长的缓存时间
(10080, '7天'), # 新增周缓存
(0, '不缓存'), # 新增不缓存选项
]
# 预设时间范围选项
DATE_RANGE_PRESETS = [
('today', '今天'),
('yesterday', '昨天'),
('this_week', '本周'),
('last_week', '上周'),
('this_month', '本月'),
('last_month', '上月'),
('this_quarter', '本季度'),
('last_quarter', '上季度'),
('this_year', '今年'),
('last_year', '去年'),
('last_3_days', '最近3天'), # 新增更短的时间范围
('last_7_days', '最近7天'),
('last_14_days', '最近14天'), # 新增两周选项
('last_30_days', '最近30天'),
('last_60_days', '最近60天'), # 新增更多选项
('last_90_days', '最近90天'),
('last_180_days', '最近半年'), # 新增半年选项
('last_365_days', '最近一年'),
('current_week_to_date', '本周至今'), # 新增至今选项
('current_month_to_date', '本月至今'),
('current_quarter_to_date', '本季度至今'),
('current_year_to_date', '今年至今'),
('custom', '自定义范围'),
]
date_range_preset = forms.ChoiceField(
label='预设时间范围',
choices=DATE_RANGE_PRESETS,
initial='last_30_days',
required=False,
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '预设时间范围',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
}),
help_text='选择预设时间范围可快速设置开始和结束日期'
)
start_date = forms.DateField(
label='开始日期',
widget=forms.DateInput(attrs={
'type': 'date',
'class': 'form-control',
'aria-label': '开始日期',
'style': 'height: 48px; font-size: 16px;', # 增大触摸区域和字体
'data-bs-toggle': 'tooltip',
'title': '报表开始日期'
}),
initial=timezone.now().date() - timedelta(days=30)
)
end_date = forms.DateField(
label='结束日期',
widget=forms.DateInput(attrs={
'type': 'date',
'class': 'form-control',
'aria-label': '结束日期',
'style': 'height: 48px; font-size: 16px;', # 增大触摸区域和字体
'data-bs-toggle': 'tooltip',
'title': '报表结束日期'
}),
initial=timezone.now().date()
)
period = forms.ChoiceField(
label='时间周期',
choices=PERIOD_CHOICES,
initial='day',
required=False,
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '时间周期'
})
)
use_cache = forms.BooleanField(
label='使用缓存',
required=False,
initial=True,
help_text='使用缓存可以提高报表生成速度,但可能不会显示最新数据',
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input',
'aria-label': '使用缓存',
'data-bs-toggle': 'tooltip',
'title': '启用缓存可显著提高报表加载速度',
'style': 'width: 20px; height: 20px;' # 增大触摸区域
})
)
cache_timeout = forms.IntegerField(
label='缓存时间(分钟)',
required=False,
initial=60,
min_value=0, # 允许设置为0表示不缓存
max_value=10080, # 7天
help_text='缓存数据的有效时间0表示不缓存',
widget=forms.NumberInput(attrs={
'class': 'form-control',
'step': '5',
'aria-label': '缓存时间',
'inputmode': 'numeric', # 在移动设备上显示数字键盘
'data-bs-toggle': 'tooltip',
'title': '设置报表数据缓存的有效时间(分钟)',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
cache_preset = forms.ChoiceField(
label='预设缓存时间',
choices=CACHE_PRESETS,
required=False,
initial=60,
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '预设缓存时间',
'data-bs-toggle': 'tooltip',
'title': '选择预设的缓存时间',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
force_refresh = forms.BooleanField(
label='强制刷新',
required=False,
initial=False,
help_text='强制重新生成报表数据,忽略缓存',
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input',
'aria-label': '强制刷新',
'data-bs-toggle': 'tooltip',
'title': '强制重新生成报表数据,忽略缓存',
'style': 'width: 20px; height: 20px;' # 增大触摸区域
})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 添加响应式布局的辅助类
for field in self.fields.values():
field.widget.attrs.update({
'class': field.widget.attrs.get('class', '') + ' mb-2', # 添加下边距
})
def _get_date_range_from_preset(self, preset):
"""根据预设值获取日期范围"""
today = timezone.now().date()
# 计算本周的开始日期(周一)
def get_week_start(d):
return d - timedelta(days=d.weekday())
# 计算本月的开始日期
def get_month_start(d):
return date(d.year, d.month, 1)
# 计算本季度的开始日期
def get_quarter_start(d):
quarter = (d.month - 1) // 3 + 1
return date(d.year, 3 * quarter - 2, 1)
# 计算本年的开始日期
def get_year_start(d):
return date(d.year, 1, 1)
if preset == 'today':
return today, today
elif preset == 'yesterday':
yesterday = today - timedelta(days=1)
return yesterday, yesterday
elif preset == 'this_week':
week_start = get_week_start(today)
return week_start, today
elif preset == 'last_week':
this_week_start = get_week_start(today)
last_week_start = this_week_start - timedelta(days=7)
last_week_end = this_week_start - timedelta(days=1)
return last_week_start, last_week_end
elif preset == 'this_month':
month_start = get_month_start(today)
return month_start, today
elif preset == 'last_month':
this_month_start = get_month_start(today)
last_month_end = this_month_start - timedelta(days=1)
last_month_start = get_month_start(last_month_end)
return last_month_start, last_month_end
elif preset == 'this_quarter':
quarter_start = get_quarter_start(today)
return quarter_start, today
elif preset == 'last_quarter':
this_quarter_start = get_quarter_start(today)
last_quarter_end = this_quarter_start - timedelta(days=1)
# 寻找上季度的开始
if this_quarter_start.month == 1: # 如果是第一季度
last_quarter_start = date(this_quarter_start.year - 1, 10, 1)
else:
last_quarter_start = date(this_quarter_start.year, this_quarter_start.month - 3, 1)
return last_quarter_start, last_quarter_end
elif preset == 'this_year':
year_start = get_year_start(today)
return year_start, today
elif preset == 'last_year':
this_year_start = get_year_start(today)
last_year_end = this_year_start - timedelta(days=1)
last_year_start = date(last_year_end.year, 1, 1)
return last_year_start, last_year_end
elif preset == 'last_3_days':
return today - timedelta(days=2), today
elif preset == 'last_7_days':
return today - timedelta(days=6), today
elif preset == 'last_14_days':
return today - timedelta(days=13), today
elif preset == 'last_30_days':
return today - timedelta(days=29), today
elif preset == 'last_60_days':
return today - timedelta(days=59), today
elif preset == 'last_90_days':
return today - timedelta(days=89), today
elif preset == 'last_180_days':
return today - timedelta(days=179), today
elif preset == 'last_365_days':
return today - timedelta(days=364), today
elif preset == 'current_week_to_date':
return get_week_start(today), today
elif preset == 'current_month_to_date':
return get_month_start(today), today
elif preset == 'current_quarter_to_date':
return get_quarter_start(today), today
elif preset == 'current_year_to_date':
return get_year_start(today), today
# 如果没有匹配的预设或选择了自定义返回None
return None, None
def clean(self):
cleaned_data = super().clean()
preset = cleaned_data.get('date_range_preset')
start_date = cleaned_data.get('start_date')
end_date = cleaned_data.get('end_date')
# 如果选择了预设日期范围(不是自定义),计算对应的开始和结束日期
if preset and preset != 'custom':
start_date, end_date = self._get_date_range_from_preset(preset)
if start_date and end_date:
cleaned_data['start_date'] = start_date
cleaned_data['end_date'] = end_date
# 确保开始日期不大于结束日期
if start_date and end_date and start_date > end_date:
self.add_error('start_date', '开始日期不能晚于结束日期')
# 如果设置了预设缓存时间,更新缓存超时
cache_preset = cleaned_data.get('cache_preset')
if cache_preset:
try:
cleaned_data['cache_timeout'] = int(cache_preset)
except (ValueError, TypeError):
pass # 如果转换失败,保持原样
# 如果强制刷新,禁用缓存
if cleaned_data.get('force_refresh'):
cleaned_data['use_cache'] = False
cleaned_data['cache_timeout'] = 0
# 给小的日期范围选择更小的缓存时间,除非用户明确选择
if not cache_preset and preset in ('today', 'yesterday', 'last_3_days'):
cleaned_data['cache_timeout'] = min(cleaned_data.get('cache_timeout', 60), 30) # 最多30分钟
return cleaned_data
def get_date_range_display(self):
"""获取日期范围的显示文本,用于报表标题等"""
preset = self.cleaned_data.get('date_range_preset')
# 如果选择了预设,返回预设显示名
if preset and preset != 'custom':
for value, label in self.DATE_RANGE_PRESETS:
if value == preset:
return label
# 如果是自定义范围,显示实际日期区间
start_date = self.cleaned_data.get('start_date')
end_date = self.cleaned_data.get('end_date')
if start_date and end_date:
if start_date == end_date:
return f"{start_date.strftime('%Y-%m-%d')}"
return f"{start_date.strftime('%Y-%m-%d')}{end_date.strftime('%Y-%m-%d')}"
# 默认情况
return '自定义日期范围'
class TopProductsForm(DateRangeForm):
"""用于热销商品报表的表单"""
limit = forms.IntegerField(
label='显示数量',
initial=10,
min_value=1,
max_value=100,
widget=forms.NumberInput(attrs={'class': 'form-control'})
)
class InventoryTurnoverForm(DateRangeForm):
"""用于库存周转报表的表单"""
category = forms.ModelChoiceField(
queryset=Category.objects.all(),
required=False,
empty_label="所有分类",
widget=forms.Select(attrs={'class': 'form-control form-select'})
)
# 添加缺失的表单类
class ReportFilterForm(DateRangeForm):
"""通用报表筛选表单继承DateRangeForm并添加分类和门店筛选"""
category = forms.ModelChoiceField(
queryset=Category.objects.filter(is_active=True),
required=False,
empty_label="所有分类",
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '商品分类',
'data-bs-toggle': 'tooltip',
'title': '按商品分类筛选报表数据',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
store = forms.ModelChoiceField(
queryset=Store.objects.filter(is_active=True), # 恢复is_active过滤只显示活跃的门店
required=False,
empty_label="所有门店",
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '门店',
'data-bs-toggle': 'tooltip',
'title': '按门店筛选报表数据',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 处理门店为空的情况
if Store.objects.count() == 0:
self.fields.pop('store', None)
class SalesReportForm(ReportFilterForm):
"""销售报表专用表单继承ReportFilterForm并添加销售相关筛选"""
SALES_TYPE_CHOICES = [
('all', '所有销售'),
('retail', '零售销售'),
('wholesale', '批发销售'),
('member', '会员销售'),
('online', '线上销售'),
]
PAYMENT_METHOD_CHOICES = [
('all', '所有支付方式'),
('cash', '现金'),
('card', '刷卡'),
('alipay', '支付宝'),
('wechat', '微信支付'),
('other', '其他方式'),
]
SORT_CHOICES = [
('date', '按日期'),
('amount', '按金额'),
('profit', '按利润'),
('quantity', '按数量'),
]
sales_type = forms.ChoiceField(
label='销售类型',
choices=SALES_TYPE_CHOICES,
required=False,
initial='all',
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '销售类型',
'data-bs-toggle': 'tooltip',
'title': '按销售类型筛选',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
payment_method = forms.ChoiceField(
label='支付方式',
choices=PAYMENT_METHOD_CHOICES,
required=False,
initial='all',
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '支付方式',
'data-bs-toggle': 'tooltip',
'title': '按支付方式筛选',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
min_amount = forms.DecimalField(
label='最小金额',
required=False,
min_value=0,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'aria-label': '最小金额',
'placeholder': '最小销售金额',
'inputmode': 'decimal', # 在移动设备上显示数字键盘
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
max_amount = forms.DecimalField(
label='最大金额',
required=False,
min_value=0,
widget=forms.NumberInput(attrs={
'class': 'form-control',
'aria-label': '最大金额',
'placeholder': '最大销售金额',
'inputmode': 'decimal', # 在移动设备上显示数字键盘
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
sort_by = forms.ChoiceField(
label='排序方式',
choices=SORT_CHOICES,
required=False,
initial='date',
widget=forms.Select(attrs={
'class': 'form-control form-select',
'aria-label': '排序方式',
'data-bs-toggle': 'tooltip',
'title': '选择报表数据的排序方式',
'style': 'height: 48px; font-size: 16px;' # 增大触摸区域和字体
})
)
include_tax = forms.BooleanField(
label='包含税费',
required=False,
initial=True,
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input',
'aria-label': '包含税费',
'data-bs-toggle': 'tooltip',
'title': '是否在报表中包含税费',
'style': 'width: 20px; height: 20px;' # 增大触摸区域
})
)
def clean(self):
cleaned_data = super().clean()
min_amount = cleaned_data.get('min_amount')
max_amount = cleaned_data.get('max_amount')
# 验证最小金额不大于最大金额
if min_amount and max_amount and min_amount > max_amount:
self.add_error('min_amount', '最小金额不能大于最大金额')
return cleaned_data