Files
wx_service/internal/expiry/service.go
T

370 lines
9.9 KiB
Go

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,
}
}