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