学校订餐菜单生成器:Django+DRF 后端与 React 前端,支持 A/B 双套营养周菜单生成、部分自选、拼音搜索、营养校准、Excel 导出

This commit is contained in:
root
2026-08-06 14:09:27 +08:00
commit 75f214dc40
48 changed files with 4684 additions and 0 deletions
+422
View File
@@ -0,0 +1,422 @@
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