Files
school-meal/backend/meals/views.py
T

423 lines
17 KiB
Python
Raw 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.
import random
from datetime import date, timedelta
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from .models import Dish
from .serializers import DishSerializer
WEEKDAYS = ['周一', '周二', '周三', '周四', '周五']
SCHOOL_NAME = '上海外国语大学附属宝山双语学校'
COLOR_HEADER = PatternFill('solid', fgColor='2F5597')
COLOR_DAY = PatternFill('solid', fgColor='D9E2F3')
COLOR_MEAT = PatternFill('solid', fgColor='FCE4D6')
COLOR_VEG = PatternFill('solid', fgColor='E2EFDA')
COLOR_STAPLE = PatternFill('solid', fgColor='FDE9D9')
COLOR_SOUP = PatternFill('solid', fgColor='DDEBF7')
THIN = Side(style='thin', color='808080')
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
CENTER = Alignment(horizontal='center', vertical='center', wrap_text=True)
class DishViewSet(viewsets.ModelViewSet):
queryset = Dish.objects.all()
serializer_class = DishSerializer
def get_queryset(self):
qs = Dish.objects.all()
dish_type = self.request.query_params.get('type')
if dish_type:
qs = qs.filter(dish_type=dish_type)
return qs
TYPE_LABEL_MAP = {'荤菜': 'meat', '素菜': 'veg', '汤': 'soup', '主食': 'staple'}
@api_view(['POST'])
@permission_classes([IsAdminUser])
def bulk_create_dishes(request):
items = request.data.get('dishes')
if not isinstance(items, list) or not items:
return Response({'detail': '请提供 dishes 列表'}, status=status.HTTP_400_BAD_REQUEST)
created, skipped = [], []
for raw in items:
if not isinstance(raw, dict):
continue
name = str(raw.get('name', '')).strip()
dish_type = str(raw.get('dish_type', '')).strip()
dish_type = TYPE_LABEL_MAP.get(dish_type, dish_type)
if not name or dish_type not in ('meat', 'veg', 'soup', 'staple'):
skipped.append(name or '未命名')
continue
defaults = {
'dish_type': dish_type,
'cuisine': str(raw.get('cuisine', '家常菜') or '家常菜'),
'ingredient_detail': str(raw.get('ingredient_detail', '') or raw.get('ingredient', '')),
'protein': float(raw.get('protein', 0) or 0),
'fat': float(raw.get('fat', 0) or 0),
'calorie': int(raw.get('calorie', 0) or 0),
'is_active': bool(raw.get('is_active', True)),
}
dish, was_created = Dish.objects.update_or_create(name=name, defaults=defaults)
(created if was_created else skipped).append(name)
return Response({
'detail': f'新增 {len(created)} 道,更新/跳过 {len(skipped)} 道',
'created': created,
'skipped': skipped,
})
def _pick_unique(pool, count):
picked = random.sample(pool, min(count, len(pool)))
remaining = [d for d in pool if d not in picked]
return picked, remaining
def _pop_next(pool, shuffle_src):
if not pool:
random.shuffle(shuffle_src)
pool = shuffle_src[:]
return pool.pop(0), pool
def _serialize_dish(d):
return {
'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type,
'cuisine': d.cuisine, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie,
}
def _generate_partial_week(payload, meats, vegs, soups, staples, menu_key):
pools = {'meat': meats[:], 'veg': vegs[:], 'soup': soups[:], 'staple': staples[:]}
src_pools = {'meat': meats, 'veg': vegs, 'soup': soups, 'staple': staples}
for p in pools.values():
random.shuffle(p)
day_selected = {}
for s in payload.get('selections') or []:
if s.get('menu', 'a') != menu_key:
continue
day = s.get('day')
slot = s.get('slot')
if day not in WEEKDAYS or slot not in ('meats', 'vegs', 'soup', 'staple'):
continue
dish = Dish.objects.filter(pk=s.get('dish_id'), is_active=True).first() if s.get('dish_id') else None
if not dish:
continue
key = f'{slot}{s.get("idx", 0)}' if slot in ('meats', 'vegs') else slot
day_selected.setdefault(day, {})[key] = dish
def fill(dish_type, used_ids):
pool = pools[dish_type]
while pool:
d = pool.pop(0)
if d.id not in used_ids:
return d
random.shuffle(src_pools[dish_type])
pool = pools[dish_type] = src_pools[dish_type][:]
while pool:
d = pool.pop(0)
if d.id not in used_ids:
return d
return None
week = []
for day in WEEKDAYS:
sel = day_selected.get(day, {})
used_ids = {d.id for d in sel.values()}
m0 = sel.get('meats0') or fill('meat', used_ids)
m1 = sel.get('meats1') or fill('meat', used_ids)
v0 = sel.get('vegs0') or fill('veg', used_ids)
v1 = sel.get('vegs1') or fill('veg', used_ids)
soup = sel.get('soup') or fill('soup', used_ids)
staple = sel.get('staple') or fill('staple', used_ids)
week.append({
'day': day,
'meats': [_serialize_dish(d) for d in (m0, m1) if d],
'vegs': [_serialize_dish(d) for d in (v0, v1) if d],
'soup': _serialize_dish(soup) if soup else None,
'staple': _serialize_dish(staple) if staple else None,
})
return week
def generate_week_menu(payload, menu_key='a'):
auto_all = payload.get('auto_all', True)
veg_per_day = 2 # 每天固定 2 个素菜
with_soup = bool(payload.get('with_soup', True))
with_staple = bool(payload.get('with_staple', True))
selected_ids = payload.get('selected_meat_ids', [])
meats = list(Dish.objects.filter(dish_type='meat', is_active=True))
vegs = list(Dish.objects.filter(dish_type='veg', is_active=True))
soups = list(Dish.objects.filter(dish_type='soup', is_active=True))
staples = list(Dish.objects.filter(dish_type='staple', is_active=True))
if payload.get('partial') or payload.get('selections'):
return _generate_partial_week(payload, meats, vegs, soups, staples, menu_key)
random.shuffle(vegs)
veg_pool = vegs[:]
random.shuffle(soups)
soup_pool = soups[:]
random.shuffle(staples)
staple_pool = staples[:]
random.shuffle(meats)
meat_pool = meats[:]
if auto_all:
day_meats = None
else:
selected = Dish.objects.filter(id__in=selected_ids, dish_type='meat')
day_meats = list(selected) + [d for d in meats if d not in selected]
day_meats = day_meats[:2]
week = []
for day in WEEKDAYS:
if auto_all:
day_meat_list = []
for _ in range(2):
item, meat_pool = _pop_next(meat_pool, meats)
day_meat_list.append(item)
else:
day_meat_list = day_meats[:] if day_meats else []
if len(day_meat_list) < 2:
extra, _ = _pick_unique([d for d in meats if d not in day_meat_list], 2 - len(day_meat_list))
day_meat_list += extra
day_veg = []
for _ in range(veg_per_day):
item, veg_pool = _pop_next(veg_pool, vegs)
day_veg.append(item)
soup = None
if with_soup and soup_pool:
soup, soup_pool = _pop_next(soup_pool, soups)
staple = None
if with_staple and staple_pool:
staple, staple_pool = _pop_next(staple_pool, staples)
week.append({
'day': day,
'meats': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine,
'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_meat_list],
'vegs': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine,
'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_veg],
'soup': {'id': soup.id, 'name': soup.name, 'ingredient': soup.ingredient_detail, 'dish_type': soup.dish_type, 'cuisine': soup.cuisine,
'protein': float(soup.protein), 'fat': float(soup.fat), 'calorie': soup.calorie} if soup else None,
'staple': {'id': staple.id, 'name': staple.name, 'ingredient': staple.ingredient_detail, 'dish_type': staple.dish_type, 'cuisine': staple.cuisine,
'protein': float(staple.protein), 'fat': float(staple.fat), 'calorie': staple.calorie} if staple else None,
})
return week
def build_menu_title(label):
today = date.today()
week_no = today.isocalendar().week
monday = today - timedelta(days=today.weekday())
friday = monday + timedelta(days=4)
date_range = f'{monday.month}.{monday.day}--{friday.month}.{friday.day}'
return f'{SCHOOL_NAME}[第{week_no}周]{date_range}营养{label}菜单'
@api_view(['POST'])
def generate_menu(request):
menus = {}
for label in ('A', 'B'):
key = label.lower()
menus[key] = {
'title': build_menu_title(label),
'week': generate_week_menu(request.data, menu_key=key),
}
return Response(menus)
def compute_daily_nutrition(week):
proteins, fats, calories = [], [], []
for day_menu in week:
items = list(day_menu['meats']) + list(day_menu['vegs'])
if day_menu.get('soup'):
items.append(day_menu['soup'])
if day_menu.get('staple'):
items.append(day_menu['staple'])
proteins.append(round(sum(x.get('protein', 0) or 0 for x in items), 1))
fats.append(round(sum(x.get('fat', 0) or 0 for x in items), 1))
calories.append(sum(x.get('calorie', 0) or 0 for x in items))
return min(proteins), max(fats), min(calories)
def nutrition_text(week):
min_p, max_f, min_c = compute_daily_nutrition(week)
return f'营养分析:蛋白质≥{min_p}g 脂肪≤{max_f}g 热量≥{min_c}kcal'
def build_week_sheet(ws, week, title, start_col=1):
headers = ['菜式', '主食', '荤菜', '素菜', '营养汤']
n_cols = len(headers)
off = start_col - 1
nutrition = nutrition_text(week)
ws.merge_cells(start_row=1, start_column=start_col, end_row=1, end_column=start_col + n_cols - 1)
cell = ws.cell(row=1, column=start_col, value=title)
cell.font = Font(size=16, bold=True)
cell.alignment = CENTER
ws.row_dimensions[1].height = 36
for col, name in enumerate(headers, start=1):
c = ws.cell(row=2, column=col + off, value=name)
c.font = Font(bold=True, color='FFFFFF')
c.fill = COLOR_HEADER
c.alignment = CENTER
c.border = BORDER
ws.row_dimensions[2].height = 26
def dish_lines(items):
lines = []
for d in items:
line = d['name']
if d.get('ingredient'):
line += f'\n({d["ingredient"]})'
lines.append(line)
return '\n'.join(lines)
for r, day_menu in enumerate(week, start=3):
values = [day_menu['day']]
values.append(dish_lines([day_menu['staple']]) if day_menu.get('staple') else '')
values.append(dish_lines(day_menu['meats']))
values.append(dish_lines(day_menu['vegs']))
values.append(dish_lines([day_menu['soup']]) if day_menu.get('soup') else '')
for col, val in enumerate(values, start=1):
c = ws.cell(row=r, column=col + off, value=val)
c.border = BORDER
if col == 1:
c.alignment = CENTER
c.fill = COLOR_DAY
c.font = Font(bold=True)
elif col == 2:
c.fill = COLOR_STAPLE
elif col == 3:
c.fill = COLOR_MEAT
elif col == 4:
c.fill = COLOR_VEG
else:
c.fill = COLOR_SOUP
if col > 1:
c.alignment = Alignment(vertical='top', horizontal='center', wrap_text=True)
ws.row_dimensions[r].height = 84
nut_row = len(week) + 3
ws.merge_cells(start_row=nut_row, start_column=start_col, end_row=nut_row, end_column=start_col + n_cols - 1)
nc = ws.cell(row=nut_row, column=start_col, value=nutrition)
nc.font = Font(bold=True, size=11)
nc.alignment = CENTER
nc.fill = PatternFill('solid', fgColor='FFF2CC')
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=nut_row, column=col).border = BORDER
ws.row_dimensions[nut_row].height = 26
seen = {}
for day_menu in week:
for d in day_menu['meats'] + day_menu['vegs']:
seen.setdefault(d['id'], d)
dishes_detail = list(seen.values())
if dishes_detail:
detail_start = nut_row + 2
ws.merge_cells(start_row=detail_start, start_column=start_col, end_row=detail_start, end_column=start_col + n_cols - 1)
dc = ws.cell(row=detail_start, column=start_col, value='本周菜品营养分析(每份)')
dc.font = Font(size=13, bold=True)
dc.alignment = CENTER
dc.fill = COLOR_HEADER
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=detail_start, column=col).border = BORDER
ws.row_dimensions[detail_start].height = 28
detail_headers = ['菜品', '类型', '蛋白质(g)', '脂肪(g)', '热量(kcal)']
for col, name in enumerate(detail_headers, start=1):
c = ws.cell(row=detail_start + 1, column=col + off, value=name)
c.font = Font(bold=True)
c.alignment = CENTER
c.border = BORDER
c.fill = PatternFill('solid', fgColor='D9E2F3')
ws.row_dimensions[detail_start + 1].height = 22
type_map = {'meat': '荤菜', 'veg': '素菜'}
for i, d in enumerate(dishes_detail, start=detail_start + 2):
row_vals = [d['name'], type_map.get(d.get('dish_type', ''), d.get('dish_type', '')),
d['protein'], d['fat'], d['calorie']]
for col, val in enumerate(row_vals, start=1):
c = ws.cell(row=i, column=col + off, value=val)
c.alignment = CENTER
c.border = BORDER
if col == 3 or col == 4:
c.number_format = '0.0'
if d.get('dish_type') == 'meat':
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=i, column=col).fill = COLOR_MEAT
else:
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=i, column=col).fill = COLOR_VEG
ws.row_dimensions[i].height = 20
total_row = detail_start + 2 + len(dishes_detail)
ws.merge_cells(start_row=total_row, start_column=start_col, end_row=total_row, end_column=start_col + n_cols - 1)
tc = ws.cell(row=total_row, column=start_col, value=nutrition)
tc.font = Font(bold=True, size=11)
tc.alignment = CENTER
tc.fill = PatternFill('solid', fgColor='FFF2CC')
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=total_row, column=col).border = BORDER
ws.row_dimensions[total_row].height = 24
widths = {1: 10, 2: 16, 3: 34, 4: 34, 5: 28}
for i in range(n_cols):
ws.column_dimensions[get_column_letter(start_col + i)].width = widths[i + 1]
return total_row if dishes_detail else nut_row
@api_view(['POST'])
def export_excel(request):
wb = Workbook()
ws = wb.active
ws.title = '周菜单'
menus = request.data.get('menus')
last_row = 0
if menus and isinstance(menus, list):
labels = {m.get('label', ''): m for m in menus}
a = labels.get('营养A菜单') or labels.get('A')
b = labels.get('营养B菜单') or labels.get('B')
if a and a.get('week'):
last_row = max(last_row, build_week_sheet(ws, a['week'], a.get('title', '营养A菜单'), start_col=1))
if b and b.get('week'):
last_row = max(last_row, build_week_sheet(ws, b['week'], b.get('title', '营养B菜单'), start_col=7))
else:
week = request.data.get('week')
if not week:
return Response({'detail': '缺少 week 数据'}, status=status.HTTP_400_BAD_REQUEST)
title = request.data.get('title') or build_menu_title('A')
last_row = build_week_sheet(ws, week, title, start_col=1)
ws.freeze_panes = 'B3'
ws.print_area = f'A1:K{last_row}'
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
week_no = date.today().isocalendar().week
response['Content-Disposition'] = f'attachment; filename="学校订餐周菜单_第{week_no}周.xlsx"'
wb.save(response)
return response