Files
cmroubao/backend-api/internal/transport/httpapi/freight_handlers.go
T

327 lines
9.0 KiB
Go

package httpapi
import (
"net/http"
"strconv"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
if !hasMediaType(ctx, "application/json") {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"UNSUPPORTED_MEDIA_TYPE",
"application/json is required",
false,
gin.H{},
)
return
}
var request struct {
Mode string `json:"mode"`
OrderNumber string `json:"order_number"`
CreatedFrom string `json:"created_from"`
CreatedTo string `json:"created_to"`
SyncToNow bool `json:"sync_to_now"`
}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return
}
var result usecase.CreateFreightSyncResult
var err error
switch request.Mode {
case domain.FreightSyncOrderNumber:
if strings.TrimSpace(request.CreatedFrom) != "" ||
strings.TrimSpace(request.CreatedTo) != "" ||
request.SyncToNow {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
false,
fieldDetails("mode", "ORDER_NUMBER cannot include date parameters"),
)
return
}
result, err = h.services.Freight.CreateOrderSync(
ctx.Request.Context(),
usecase.CreateFreightSyncCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
OrderNumber: request.OrderNumber,
},
)
case domain.FreightSyncCreatedRange:
if strings.TrimSpace(request.OrderNumber) != "" {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"FREIGHT_SYNC_INVALID",
"freight sync request is invalid",
false,
fieldDetails("mode", "CREATED_RANGE cannot include order_number"),
)
return
}
result, err = h.services.Freight.CreateDateSync(
ctx.Request.Context(),
usecase.CreateFreightDateSyncCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
CreatedFrom: request.CreatedFrom,
CreatedTo: request.CreatedTo,
SyncToNow: request.SyncToNow,
},
)
default:
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"FREIGHT_SYNC_MODE_INVALID",
"freight sync mode is not supported",
false,
fieldDetails("mode", "must be ORDER_NUMBER or CREATED_RANGE"),
)
return
}
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
status := http.StatusAccepted
if request.Mode == domain.FreightSyncOrderNumber {
status = http.StatusCreated
if result.Replayed {
status = http.StatusOK
}
ctx.Header("Location", "/api/v1/freight-orders")
}
ctx.JSON(status, gin.H{
"sync": freightSyncResponse(result.Run),
"replayed": result.Replayed,
})
}
func (h *adminHandlers) freightSyncWatermark(ctx *gin.Context) {
watermark, err := h.services.Freight.GetWatermark(
ctx.Request.Context(),
localAdminSubject,
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
if watermark == nil {
ctx.JSON(http.StatusOK, gin.H{"watermark": nil})
return
}
ctx.JSON(http.StatusOK, gin.H{"watermark": gin.H{
"source_system": watermark.SourceSystem,
"last_successful_to": formatTime(watermark.LastSuccessfulTo),
"last_successful_run_id": watermark.LastSuccessfulRunID,
"updated_at": formatTime(watermark.UpdatedAt),
}})
}
func (h *adminHandlers) freightSyncDetail(ctx *gin.Context) {
run, err := h.services.Freight.GetSync(
ctx.Request.Context(),
localAdminSubject,
ctx.Param("id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"sync": freightSyncResponse(run)})
}
func (h *adminHandlers) listFreightOrders(ctx *gin.Context) {
limit := 0
if value := strings.TrimSpace(ctx.Query("limit")); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"FREIGHT_LIST_INVALID",
"freight list filter is invalid",
false,
fieldDetails("limit", "must be an integer"),
)
return
}
limit = parsed
}
orders, err := h.services.Freight.ListOrders(
ctx.Request.Context(),
localAdminSubject,
limit,
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
items := make([]gin.H, 0, len(orders))
for _, order := range orders {
items = append(items, freightOrderResponse(order))
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"items": items,
"next_cursor": nil,
})
}
func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
detail, err := h.services.Freight.GetOrder(
ctx.Request.Context(),
localAdminSubject,
ctx.Param("id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
items := make([]gin.H, 0, len(detail.Items))
for _, item := range detail.Items {
var imageURL any
if item.ImageStatus == domain.FreightItemImageReady {
imageURL = "/api/v1/freight-items/" + item.ID + "/image"
}
items = append(items, gin.H{
"id": item.ID,
"external_item_id": item.ExternalItemID,
"title": item.Title,
"product_spec": item.ProductSpec,
"sku": item.SKU,
"quantity": item.Quantity,
"product_thumb_ref": item.ProductThumbRef,
"original_unit_price_minor": item.OriginalUnitPriceMinor,
"original_currency": item.OriginalCurrency,
"image_status": item.ImageStatus,
"image_error_code": item.ImageErrorCode,
"image_url": imageURL,
"purchase_status": item.PurchaseStatus,
"revision": item.Revision,
"canonical_sha256": item.CanonicalSHA256,
"updated_at": formatTime(item.UpdatedAt),
})
}
ctx.Header("Cache-Control", "no-store")
response := gin.H{
"order": freightOrderResponse(detail.Order),
"items": items,
}
if h.services.Procurement != nil {
requests, requestErr := h.services.Procurement.ListForOrder(
ctx.Request.Context(),
localAdminSubject,
detail.Order.ID,
)
if requestErr != nil {
writeUsecaseError(ctx, requestErr)
return
}
requestItems := make([]gin.H, 0, len(requests))
for _, request := range requests {
requestItems = append(
requestItems,
procurementRequestResponse(request),
)
}
response["procurement_requests"] = requestItems
}
ctx.JSON(http.StatusOK, response)
}
func (h *adminHandlers) deleteFreightOrder(ctx *gin.Context) {
err := h.services.Freight.DeleteOrder(
ctx.Request.Context(),
usecase.DeleteFreightOrderCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
OrderID: ctx.Param("id"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.Status(http.StatusNoContent)
}
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
return gin.H{
"id": run.ID,
"mode": run.Mode,
"created_from": nullableResponseString(run.CreatedFrom),
"created_to": nullableResponseString(run.CreatedTo),
"watermark_through": formatOptionalTime(run.WatermarkThrough),
"query_sha256": run.QuerySHA256,
"status": run.Status,
"error_code": run.ErrorCode,
"order_count": run.OrderCount,
"item_count": run.ItemCount,
"created_at": formatTime(run.CreatedAt),
"started_at": formatOptionalTime(run.StartedAt),
"finished_at": formatOptionalTime(run.FinishedAt),
}
}
func nullableResponseString(value string) any {
if value == "" {
return nil
}
return value
}
func freightOrderResponse(order domain.FreightOrder) gin.H {
var previewImageURL any
if order.PreviewImageStatus == domain.FreightItemImageReady {
previewImageURL = "/api/v1/freight-items/" +
order.PreviewItemID + "/image"
}
return gin.H{
"id": order.ID,
"source_system": order.SourceSystem,
"external_stock_id": order.ExternalStockID,
"source_code": order.SourceCode,
"platform_order_no": order.PlatformOrderNo,
"shop_name": order.ShopName,
"source_created_at": formatOptionalTime(order.SourceCreatedAt),
"order_status": order.OrderStatus,
"purchase_status": order.PurchaseStatus,
"is_canceled": order.IsCanceled,
"revision": order.Revision,
"canonical_sha256": order.CanonicalSHA256,
"item_count": order.ItemCount,
"preview_item_id": nullableResponseString(order.PreviewItemID),
"preview_title": nullableResponseString(order.PreviewTitle),
"preview_image_status": order.PreviewImageStatus,
"preview_image_url": previewImageURL,
"updated_at": formatTime(order.UpdatedAt),
}
}