485 lines
13 KiB
Go
485 lines
13 KiB
Go
package expiry
|
|
|
|
import (
|
|
"context"
|
|
"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")
|
|
ErrExpiryRemindDaysInvalid = errors.New("remind_days 必须是 1-30 的整数,数量 1-5 个")
|
|
)
|
|
|
|
// Service 封装保质期模块业务逻辑。
|
|
type Service struct {
|
|
repo *Repository
|
|
summaryCache *SummaryCache
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// SettingsResponse 用户提醒设置返回结构。
|
|
type SettingsResponse struct {
|
|
RemindDays []int `json:"remind_days"`
|
|
}
|
|
|
|
func NewService(repo *Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
// BindSummaryCache 绑定 summary 的 Redis 缓存(可选)。
|
|
func (s *Service) BindSummaryCache(cache *SummaryCache) {
|
|
s.summaryCache = cache
|
|
}
|
|
|
|
// 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
|
|
}
|
|
s.invalidateSummaryCache(userID)
|
|
|
|
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
|
|
}
|
|
s.invalidateSummaryCache(userID)
|
|
|
|
item.Status = item.CalculateStatus()
|
|
return item, nil
|
|
}
|
|
|
|
// DeleteItem 删除物品。
|
|
func (s *Service) DeleteItem(id, userID uint) error {
|
|
if err := s.repo.Delete(id, userID); err != nil {
|
|
return err
|
|
}
|
|
s.invalidateSummaryCache(userID)
|
|
return nil
|
|
}
|
|
|
|
// 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) {
|
|
if cached, ok := s.getSummaryFromCache(userID); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
data, err := s.repo.GetSummary(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
summary := &SummaryResponse{
|
|
TotalItems: data["total_items"],
|
|
ExpiringSoon: data["expiring_soon"],
|
|
Expired: data["expired"],
|
|
Normal: data["normal"],
|
|
Used: data["used"],
|
|
Discarded: data["discarded"],
|
|
}
|
|
|
|
s.setSummaryToCache(userID, summary)
|
|
return summary, nil
|
|
}
|
|
|
|
// UpdateItemStatus 标记物品状态。
|
|
func (s *Service) UpdateItemStatus(id, userID uint, status string) error {
|
|
status = strings.TrimSpace(status)
|
|
if status != StatusUsed && status != StatusDiscarded {
|
|
return ErrExpiryStatusInvalid
|
|
}
|
|
if err := s.repo.UpdateStatus(id, userID, status); err != nil {
|
|
return err
|
|
}
|
|
s.invalidateSummaryCache(userID)
|
|
return nil
|
|
}
|
|
|
|
// GetSettings 获取用户提醒设置;若未配置则返回默认值。
|
|
func (s *Service) GetSettings(userID uint) (*SettingsResponse, error) {
|
|
settings, err := s.repo.GetSettings(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if settings == nil || len(settings.RemindDays) == 0 {
|
|
return &SettingsResponse{
|
|
RemindDays: copyIntSlice(defaultRemindDays),
|
|
}, nil
|
|
}
|
|
|
|
return &SettingsResponse{
|
|
RemindDays: copyIntSlice(settings.RemindDays),
|
|
}, nil
|
|
}
|
|
|
|
// UpdateSettings 更新用户提醒设置。
|
|
func (s *Service) UpdateSettings(userID uint, remindDays []int) (*SettingsResponse, error) {
|
|
validated, err := validateRemindDays(remindDays)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
settings, err := s.repo.UpdateSettings(userID, validated)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &SettingsResponse{
|
|
RemindDays: copyIntSlice(settings.RemindDays),
|
|
}, nil
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
func validateRemindDays(days []int) ([]int, error) {
|
|
if len(days) == 0 || len(days) > 5 {
|
|
return nil, ErrExpiryRemindDaysInvalid
|
|
}
|
|
|
|
seen := make(map[int]struct{}, len(days))
|
|
result := make([]int, 0, len(days))
|
|
for _, day := range days {
|
|
if day < 1 || day > 30 {
|
|
return nil, ErrExpiryRemindDaysInvalid
|
|
}
|
|
if _, ok := seen[day]; ok {
|
|
continue
|
|
}
|
|
seen[day] = struct{}{}
|
|
result = append(result, day)
|
|
}
|
|
|
|
if len(result) == 0 || len(result) > 5 {
|
|
return nil, ErrExpiryRemindDaysInvalid
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) getSummaryFromCache(userID uint) (*SummaryResponse, bool) {
|
|
if s.summaryCache == nil {
|
|
return nil, false
|
|
}
|
|
summary, ok, err := s.summaryCache.Get(context.Background(), userID)
|
|
if err != nil || !ok || summary == nil {
|
|
return nil, false
|
|
}
|
|
return summary, true
|
|
}
|
|
|
|
func (s *Service) setSummaryToCache(userID uint, summary *SummaryResponse) {
|
|
if s.summaryCache == nil || summary == nil {
|
|
return
|
|
}
|
|
_ = s.summaryCache.Set(context.Background(), userID, summary)
|
|
}
|
|
|
|
func (s *Service) invalidateSummaryCache(userID uint) {
|
|
if s.summaryCache == nil {
|
|
return
|
|
}
|
|
_ = s.summaryCache.Delete(context.Background(), userID)
|
|
}
|