feat(expiry): 完成 #23 物品 Service 层实现
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user