From e982cdc77350c56f493f6bc685e60b679d6c4843 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 4 Mar 2026 17:10:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(expiry):=20=E5=AE=8C=E6=88=90=20#23=20?= =?UTF-8?q?=E7=89=A9=E5=93=81=20Service=20=E5=B1=82=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/expiry/service.go | 359 +++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) diff --git a/internal/expiry/service.go b/internal/expiry/service.go index 82a1fb0..1017a98 100644 --- a/internal/expiry/service.go +++ b/internal/expiry/service.go @@ -1,10 +1,369 @@ package expiry +import ( + "errors" + "strings" + "time" +) + +var ( + ErrExpiryNameInvalid = errors.New("物品名称不能为空且长度不能超过100") + ErrExpiryCategoryInvalid = errors.New("分类无效") + ErrExpiryMiniProgramRequired = errors.New("缺少小程序ID") + ErrExpiryQuantityInvalid = errors.New("数量必须大于0") + ErrExpiryLocationTooLong = errors.New("存放位置长度不能超过50") + ErrExpiryRemarkTooLong = errors.New("备注长度不能超过255") + ErrExpiryDateRequired = errors.New("必须提供过期日期,或提供生产日期+保质期天数") + ErrExpiryShelfLifeDaysInvalid = errors.New("保质期天数必须大于0") + ErrExpiryFilterStatusInvalid = errors.New("status 参数无效") + ErrExpiryFilterCategoryInvalid = errors.New("category 参数无效") + ErrExpiryFilterSortInvalid = errors.New("sort 参数无效") + ErrExpiryStatusInvalid = errors.New("状态无效,仅支持 used/discarded") +) + // Service 封装保质期模块业务逻辑。 type Service struct { repo *Repository } +// CreateItemRequest 创建物品请求。 +type CreateItemRequest struct { + MiniProgramID uint + Name string + Category string + ProductionDate *time.Time + ExpiryDate *time.Time + ShelfLifeDays *int + Quantity int + Location string + Remark string +} + +// UpdateItemRequest 更新物品请求(当前按全量更新处理)。 +type UpdateItemRequest = CreateItemRequest + +// ItemFilters 列表筛选参数。 +type ItemFilters struct { + Status string + Category string + Sort string + Page int + PageSize int +} + +// ItemView 是返回给前端的物品视图。 +type ItemView struct { + ID uint `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + ProductionDate *time.Time `json:"production_date,omitempty"` + ExpiryDate time.Time `json:"expiry_date"` + ShelfLifeDays *int `json:"shelf_life_days,omitempty"` + Quantity int `json:"quantity"` + Location string `json:"location"` + Remark string `json:"remark"` + Status string `json:"status"` + DaysLeft int `json:"days_left"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ItemListResponse 列表返回结构。 +type ItemListResponse struct { + Items []ItemView `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// SummaryResponse 首页汇总结构。 +type SummaryResponse struct { + TotalItems int `json:"total_items"` + ExpiringSoon int `json:"expiring_soon"` + Expired int `json:"expired"` + Normal int `json:"normal"` + Used int `json:"used"` + Discarded int `json:"discarded"` +} + func NewService(repo *Repository) *Service { return &Service{repo: repo} } + +// CreateItem 创建物品,并处理过期日期自动计算。 +func (s *Service) CreateItem(userID uint, req CreateItemRequest) (*ExpiryItem, error) { + if req.MiniProgramID == 0 { + return nil, ErrExpiryMiniProgramRequired + } + if err := validateBaseFields(req.Name, req.Category, req.Quantity, req.Location, req.Remark); err != nil { + return nil, err + } + if req.ShelfLifeDays != nil && *req.ShelfLifeDays <= 0 { + return nil, ErrExpiryShelfLifeDaysInvalid + } + + item := &ExpiryItem{ + UserID: userID, + MiniProgramID: req.MiniProgramID, + Name: strings.TrimSpace(req.Name), + Category: strings.TrimSpace(req.Category), + Quantity: req.Quantity, + Location: strings.TrimSpace(req.Location), + Remark: strings.TrimSpace(req.Remark), + Status: StatusNormal, + } + + if item.Quantity == 0 { + item.Quantity = 1 + } + + if req.ProductionDate != nil { + pd := dateOnly(*req.ProductionDate) + item.ProductionDate = &pd + } + + if err := applyExpiryDate(item, req.ExpiryDate, req.ShelfLifeDays); err != nil { + return nil, err + } + + if err := s.repo.Create(item); err != nil { + return nil, err + } + + item.Status = item.CalculateStatus() + return item, nil +} + +// UpdateItem 更新物品。 +func (s *Service) UpdateItem(id, userID uint, req UpdateItemRequest) (*ExpiryItem, error) { + item, err := s.repo.FindByID(id, userID) + if err != nil { + return nil, err + } + + if err := validateBaseFields(req.Name, req.Category, req.Quantity, req.Location, req.Remark); err != nil { + return nil, err + } + if req.ShelfLifeDays != nil && *req.ShelfLifeDays <= 0 { + return nil, ErrExpiryShelfLifeDaysInvalid + } + + item.Name = strings.TrimSpace(req.Name) + item.Category = strings.TrimSpace(req.Category) + item.Quantity = req.Quantity + item.Location = strings.TrimSpace(req.Location) + item.Remark = strings.TrimSpace(req.Remark) + item.MiniProgramID = req.MiniProgramID + item.ProductionDate = nil + item.ShelfLifeDays = nil + + if req.MiniProgramID == 0 { + return nil, ErrExpiryMiniProgramRequired + } + if item.Quantity == 0 { + item.Quantity = 1 + } + if req.ProductionDate != nil { + pd := dateOnly(*req.ProductionDate) + item.ProductionDate = &pd + } + + if err := applyExpiryDate(item, req.ExpiryDate, req.ShelfLifeDays); err != nil { + return nil, err + } + + // 已标记为 used/discarded 的物品保留状态,其余回归可计算状态。 + if item.Status != StatusUsed && item.Status != StatusDiscarded { + item.Status = StatusNormal + } + + if err := s.repo.Update(item); err != nil { + return nil, err + } + + item.Status = item.CalculateStatus() + return item, nil +} + +// DeleteItem 删除物品。 +func (s *Service) DeleteItem(id, userID uint) error { + return s.repo.Delete(id, userID) +} + +// GetItem 获取单个物品。 +func (s *Service) GetItem(id, userID uint) (*ExpiryItem, error) { + item, err := s.repo.FindByID(id, userID) + if err != nil { + return nil, err + } + item.Status = item.CalculateStatus() + return item, nil +} + +// GetItems 获取列表并计算 days_left/status。 +func (s *Service) GetItems(userID uint, filters ItemFilters) (*ItemListResponse, error) { + filters = normalizeFilters(filters) + if err := validateFilters(filters); err != nil { + return nil, err + } + + items, total, err := s.repo.FindByUser(userID, map[string]interface{}{ + "status": filters.Status, + "category": filters.Category, + "sort": filters.Sort, + }, filters.Page, filters.PageSize) + if err != nil { + return nil, err + } + + result := make([]ItemView, 0, len(items)) + for _, item := range items { + result = append(result, toItemView(item)) + } + + return &ItemListResponse{ + Items: result, + Total: total, + Page: filters.Page, + PageSize: filters.PageSize, + }, nil +} + +// GetSummary 获取首页汇总。 +func (s *Service) GetSummary(userID uint) (*SummaryResponse, error) { + data, err := s.repo.GetSummary(userID) + if err != nil { + return nil, err + } + + return &SummaryResponse{ + TotalItems: data["total_items"], + ExpiringSoon: data["expiring_soon"], + Expired: data["expired"], + Normal: data["normal"], + Used: data["used"], + Discarded: data["discarded"], + }, nil +} + +// UpdateItemStatus 标记物品状态。 +func (s *Service) UpdateItemStatus(id, userID uint, status string) error { + status = strings.TrimSpace(status) + if status != StatusUsed && status != StatusDiscarded { + return ErrExpiryStatusInvalid + } + return s.repo.UpdateStatus(id, userID, status) +} + +func validateBaseFields(name, category string, quantity int, location, remark string) error { + name = strings.TrimSpace(name) + category = strings.TrimSpace(category) + location = strings.TrimSpace(location) + remark = strings.TrimSpace(remark) + + if name == "" || len(name) > 100 { + return ErrExpiryNameInvalid + } + if !isValidCategory(category) { + return ErrExpiryCategoryInvalid + } + if quantity < 0 { + return ErrExpiryQuantityInvalid + } + if len(location) > 50 { + return ErrExpiryLocationTooLong + } + if len(remark) > 255 { + return ErrExpiryRemarkTooLong + } + return nil +} + +func applyExpiryDate(item *ExpiryItem, expiryDate *time.Time, shelfLifeDays *int) error { + // 若提供生产日期 + 保质期,优先自动计算过期日期。 + if item.ProductionDate != nil && shelfLifeDays != nil { + item.ShelfLifeDays = shelfLifeDays + item.ExpiryDate = dateOnly(item.ProductionDate.AddDate(0, 0, *shelfLifeDays)) + return nil + } + + if expiryDate != nil { + item.ExpiryDate = dateOnly(*expiryDate) + item.ShelfLifeDays = shelfLifeDays + return nil + } + + return ErrExpiryDateRequired +} + +func normalizeFilters(filters ItemFilters) ItemFilters { + if filters.Page <= 0 { + filters.Page = 1 + } + if filters.PageSize <= 0 { + filters.PageSize = 20 + } + if filters.PageSize > 100 { + filters.PageSize = 100 + } + if strings.TrimSpace(filters.Status) == "" { + filters.Status = "all" + } + if strings.TrimSpace(filters.Category) == "" { + filters.Category = "all" + } + if strings.TrimSpace(filters.Sort) == "" { + filters.Sort = "expiry_date" + } + return filters +} + +func validateFilters(filters ItemFilters) error { + switch filters.Status { + case "all", StatusExpiring, StatusExpired, StatusNormal, StatusUsed, StatusDiscarded: + default: + return ErrExpiryFilterStatusInvalid + } + + switch filters.Category { + case "all", CategoryFood, CategoryMedicine, CategoryCosmetic, CategoryOther: + default: + return ErrExpiryFilterCategoryInvalid + } + + switch filters.Sort { + case "expiry_date", "created_at": + default: + return ErrExpiryFilterSortInvalid + } + + return nil +} + +func isValidCategory(category string) bool { + switch category { + case CategoryFood, CategoryMedicine, CategoryCosmetic, CategoryOther: + return true + default: + return false + } +} + +func toItemView(item ExpiryItem) ItemView { + calculatedStatus := item.CalculateStatus() + return ItemView{ + ID: item.ID, + Name: item.Name, + Category: item.Category, + ProductionDate: item.ProductionDate, + ExpiryDate: item.ExpiryDate, + ShelfLifeDays: item.ShelfLifeDays, + Quantity: item.Quantity, + Location: item.Location, + Remark: item.Remark, + Status: calculatedStatus, + DaysLeft: item.CalculateDaysLeft(), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +}