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.permissions import AllowAny from rest_framework.response import Response from .models import Cuisine, Dish from .serializers import DishSerializer, CuisineSerializer 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 CuisineViewSet(viewsets.ModelViewSet): queryset = Cuisine.objects.all() serializer_class = CuisineSerializer permission_classes = [AllowAny] class DishViewSet(viewsets.ModelViewSet): queryset = Dish.objects.all() serializer_class = DishSerializer permission_classes = [AllowAny] 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 # Resolve cuisine FK by name cuisine_name = str(raw.get('cuisine', '') or '').strip() cuisine_obj = None if cuisine_name: cuisine_obj, _ = Cuisine.objects.get_or_create(name=cuisine_name) defaults = { 'dish_type': dish_type, 'cuisine': cuisine_obj, '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_name, '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_name, '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_name, '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_name, '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_name, 'protein': float(staple.protein), 'fat': float(staple.fat), 'calorie': staple.calorie} if staple else None, }) return week def build_menu_title(label, school_name=None, week_no=None, date_start=None, date_end=None): school_name = school_name or SCHOOL_NAME iso = date.today().isocalendar() try: week_no = int(week_no) if week_no is not None else iso.week except (TypeError, ValueError): week_no = iso.week if not 1 <= week_no <= 53: week_no = iso.week # Use custom dates if provided, otherwise compute from week number if date_start and date_end: date_range = f'{date_start}--{date_end}' else: try: monday = date.fromisocalendar(iso.year, week_no, 1) except ValueError: monday = date.today() - timedelta(days=date.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): data = request.data school = data.get('school_name') or SCHOOL_NAME week_no = data.get('week_no') date_start = data.get('date_start') date_end = data.get('date_end') menus = {} for label in ('A', 'B'): key = label.lower() menus[key] = { 'title': build_menu_title(label, school, week_no, date_start, date_end), 'week': generate_week_menu(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