feat(t242): delete unused freight orders
This commit is contained in:
@@ -75,6 +75,7 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
)
|
||||
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
|
||||
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
||||
routes.DELETE("/api/v1/freight-orders/:id", handler.deleteFreightOrder)
|
||||
}
|
||||
if services.FreightImages != nil {
|
||||
routes.GET(
|
||||
|
||||
@@ -534,6 +534,92 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
||||
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
|
||||
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
|
||||
}
|
||||
deletedOrderID := listBody.Items[0].ID
|
||||
deletedImageURL := detailBody.Items[0].ImageURL
|
||||
deleteResponse := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodDelete,
|
||||
"/api/v1/freight-orders/"+deletedOrderID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if deleteResponse.Code != http.StatusNoContent ||
|
||||
deleteResponse.Body.Len() != 0 ||
|
||||
deleteResponse.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf(
|
||||
"delete status/body/headers = %d / %s / %#v",
|
||||
deleteResponse.Code,
|
||||
deleteResponse.Body,
|
||||
deleteResponse.Header(),
|
||||
)
|
||||
}
|
||||
deletedDetail := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders/"+deletedOrderID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
deletedImage := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
deletedImageURL,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
repeatedDelete := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodDelete,
|
||||
"/api/v1/freight-orders/"+deletedOrderID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if deletedDetail.Code != http.StatusNotFound ||
|
||||
deletedImage.Code != http.StatusNotFound ||
|
||||
repeatedDelete.Code != http.StatusNoContent {
|
||||
t.Fatalf(
|
||||
"after delete detail/image/replay = %d / %d / %d",
|
||||
deletedDetail.Code,
|
||||
deletedImage.Code,
|
||||
repeatedDelete.Code,
|
||||
)
|
||||
}
|
||||
reimport := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(
|
||||
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
|
||||
),
|
||||
"freight-sync-after-delete",
|
||||
)
|
||||
if reimport.Code != http.StatusCreated {
|
||||
t.Fatalf("reimport status/body = %d / %s", reimport.Code, reimport.Body)
|
||||
}
|
||||
reimportedList := performAdminRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders",
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
decodeResponse(t, reimportedList, &listBody)
|
||||
if len(listBody.Items) != 1 ||
|
||||
listBody.Items[0].ID == deletedOrderID {
|
||||
t.Fatalf("reimported orders = %+v", listBody.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFreightDateSyncAdvancesInspectableWatermark(t *testing.T) {
|
||||
@@ -687,6 +773,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||
createRequest.Body,
|
||||
)
|
||||
}
|
||||
deleteInUse := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodDelete,
|
||||
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if deleteInUse.Code != http.StatusConflict ||
|
||||
!strings.Contains(
|
||||
deleteInUse.Body.String(),
|
||||
`"code":"FREIGHT_ORDER_IN_USE"`,
|
||||
) {
|
||||
t.Fatalf(
|
||||
"delete in-use status/body = %d / %s",
|
||||
deleteInUse.Code,
|
||||
deleteInUse.Body,
|
||||
)
|
||||
}
|
||||
var requestBody struct {
|
||||
Request struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -255,6 +255,23 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
||||
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,
|
||||
|
||||
@@ -89,6 +89,11 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
|
||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
||||
routes.POST(
|
||||
"/freight/:id/delete",
|
||||
SecurityHeaders(),
|
||||
h.DeleteFreightOrder,
|
||||
)
|
||||
}
|
||||
if _, ok := h.service.(ProcurementService); ok {
|
||||
routes.POST(
|
||||
@@ -527,6 +532,57 @@ func (h *Handler) FreightDetail(ctx *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteFreightOrder(ctx *gin.Context) {
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
||||
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusForbidden,
|
||||
"请求已失效",
|
||||
"请返回货运详情后重新操作。",
|
||||
)
|
||||
return
|
||||
}
|
||||
if ctx.PostForm("confirm_delete") != "1" {
|
||||
h.renderError(
|
||||
ctx,
|
||||
http.StatusUnprocessableEntity,
|
||||
"必须确认删除",
|
||||
"请确认只删除本系统中的货运信息后再提交。",
|
||||
)
|
||||
return
|
||||
}
|
||||
orderID := strings.TrimSpace(ctx.Param("id"))
|
||||
service := h.service.(FreightService)
|
||||
err := service.DeleteFreightOrder(
|
||||
ctx.Request.Context(),
|
||||
DeleteFreightOrderInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
OrderID: orderID,
|
||||
},
|
||||
)
|
||||
if errors.Is(err, ErrFreightOrderInUse) {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=delete-conflict",
|
||||
)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.renderServiceError(
|
||||
ctx,
|
||||
err,
|
||||
"货运单删除失败,请稍后重试。",
|
||||
)
|
||||
return
|
||||
}
|
||||
h.logEvent(
|
||||
"freight_order_deleted order_id=" + orderID +
|
||||
" actor_user_id=" + actorUserID(ctx.Request.Context()),
|
||||
)
|
||||
ctx.Redirect(http.StatusSeeOther, "/freight?notice=deleted")
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFreightProcurementRequest(ctx *gin.Context) {
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
||||
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
||||
@@ -662,6 +718,10 @@ func freightNotice(value string) string {
|
||||
return "需求状态或来源已变化,当前不能生成任务。"
|
||||
case "import-succeeded":
|
||||
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
||||
case "deleted":
|
||||
return "货运单已从本系统删除,不影响顺运宝 ERP。"
|
||||
case "delete-conflict":
|
||||
return "该货运单已进入采购流程,不能删除。"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1301,6 +1301,102 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
|
||||
service := &fakeFreightService{
|
||||
fakeService: &fakeService{},
|
||||
orderDetail: FreightOrderDetail{
|
||||
Order: FreightOrder{
|
||||
ID: testTaskID,
|
||||
ExternalStockID: "12",
|
||||
SourceCode: "SOURCE-12",
|
||||
},
|
||||
},
|
||||
}
|
||||
logged := make([]string, 0)
|
||||
router := newTestRouterWithLogger(
|
||||
t,
|
||||
service,
|
||||
func(value string) {
|
||||
logged = append(logged, value)
|
||||
},
|
||||
)
|
||||
detail := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/freight/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if detail.Code != http.StatusOK ||
|
||||
!strings.Contains(detail.Body.String(), "删除本地货运单") ||
|
||||
!strings.Contains(detail.Body.String(), "data-freight-delete-dialog") {
|
||||
t.Fatalf("delete controls status/body = %d / %s", detail.Code, detail.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, detail)
|
||||
|
||||
withoutCSRF := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/freight/"+testTaskID+"/delete",
|
||||
strings.NewReader("confirm_delete=1"),
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
if withoutCSRF.Code != http.StatusForbidden {
|
||||
t.Fatalf("delete without CSRF status = %d", withoutCSRF.Code)
|
||||
}
|
||||
|
||||
values := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"confirm_delete": {"1"},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/"+testTaskID+"/delete",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") != "/freight?notice=deleted" ||
|
||||
service.deleteInput.OrderID != testTaskID ||
|
||||
len(logged) != 1 ||
|
||||
!strings.Contains(logged[0], "freight_order_deleted order_id=") {
|
||||
t.Fatalf(
|
||||
"delete response/input/log = %d / %q / %+v / %#v",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
service.deleteInput,
|
||||
logged,
|
||||
)
|
||||
}
|
||||
|
||||
service.deleteErr = ErrFreightOrderInUse
|
||||
request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/"+testTaskID+"/delete",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/"+testTaskID+"?notice=delete-conflict" ||
|
||||
len(logged) != 1 {
|
||||
t.Fatalf(
|
||||
"conflicting delete status/location/log = %d / %q / %#v",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
logged,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightPresentationFormatsMoneyAndImageStates(t *testing.T) {
|
||||
value := int64(12905)
|
||||
if got := formatMinorCurrency(&value, "TWD"); got != "TWD 129.05" {
|
||||
@@ -1348,6 +1444,8 @@ type fakeFreightService struct {
|
||||
*fakeService
|
||||
orders []FreightOrder
|
||||
orderDetail FreightOrderDetail
|
||||
deleteInput DeleteFreightOrderInput
|
||||
deleteErr error
|
||||
sync FreightSync
|
||||
createInput CreateFreightSyncInput
|
||||
createResult FreightSync
|
||||
@@ -1448,6 +1546,14 @@ func (service *fakeFreightService) GetFreightOrder(
|
||||
return service.orderDetail, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) DeleteFreightOrder(
|
||||
_ context.Context,
|
||||
input DeleteFreightOrderInput,
|
||||
) error {
|
||||
service.deleteInput = input
|
||||
return service.deleteErr
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
|
||||
@@ -119,4 +119,23 @@
|
||||
openCancel.focus();
|
||||
});
|
||||
}
|
||||
|
||||
const openFreightDelete = document.querySelector(
|
||||
"[data-open-freight-delete]",
|
||||
);
|
||||
const freightDeleteDialog = document.querySelector(
|
||||
"[data-freight-delete-dialog]",
|
||||
);
|
||||
if (openFreightDelete && freightDeleteDialog) {
|
||||
openFreightDelete.addEventListener("click", () => {
|
||||
freightDeleteDialog.showModal();
|
||||
freightDeleteDialog.querySelector("[data-keep-freight-order]").focus();
|
||||
});
|
||||
freightDeleteDialog
|
||||
.querySelector("[data-keep-freight-order]")
|
||||
.addEventListener("click", () => {
|
||||
freightDeleteDialog.close();
|
||||
openFreightDelete.focus();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
|
||||
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
<div class="title-actions freight-title-actions">
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
<button class="button danger-outline" type="button"
|
||||
data-open-freight-delete>删除本地货运单</button>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
|
||||
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||
@@ -129,6 +133,38 @@
|
||||
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<noscript>
|
||||
<section class="noscript-cancel" aria-labelledby="freight-delete-noscript-title">
|
||||
<h2 id="freight-delete-noscript-title">删除本地货运单</h2>
|
||||
<p>只删除本系统保存的信息,不影响顺运宝 ERP;已进入采购流程的货运单不能删除。</p>
|
||||
<form method="post" action="/freight/{{pathPart .Detail.Order.ID}}/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
<label class="confirm-line">
|
||||
<input type="checkbox" name="confirm_delete" value="1" required>
|
||||
<span>已确认删除本系统中的货运信息</span>
|
||||
</label>
|
||||
<button class="button danger" type="submit">确认删除</button>
|
||||
</form>
|
||||
</section>
|
||||
</noscript>
|
||||
|
||||
<dialog class="confirm-dialog" data-freight-delete-dialog
|
||||
aria-labelledby="freight-delete-title">
|
||||
<h2 id="freight-delete-title">删除本地货运单?</h2>
|
||||
<p>将删除本系统保存的 {{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}该货运单{{end}} 及商品图片,不影响顺运宝 ERP。已进入采购流程时系统会拒绝删除。</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" type="button"
|
||||
data-keep-freight-order>保留货运单</button>
|
||||
<form method="post" action="/freight/{{pathPart .Detail.Order.ID}}/delete"
|
||||
data-submit-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
<input type="hidden" name="confirm_delete" value="1">
|
||||
<button class="button danger" type="submit" data-submit-button
|
||||
data-loading-label="正在删除…">确认删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -24,6 +24,7 @@ var (
|
||||
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
||||
ErrFreightSyncBusy = errors.New("freight sync is busy")
|
||||
ErrFreightSyncTimeout = errors.New("freight sync timed out")
|
||||
ErrFreightOrderInUse = errors.New("freight order is in use")
|
||||
)
|
||||
|
||||
// Service is the application boundary required by the server-rendered admin UI.
|
||||
@@ -40,6 +41,7 @@ type Service interface {
|
||||
type FreightService interface {
|
||||
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
||||
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
||||
DeleteFreightOrder(context.Context, DeleteFreightOrderInput) error
|
||||
GetFreightSync(context.Context, string) (FreightSync, error)
|
||||
GetFreightWatermark(context.Context) (*FreightWatermark, error)
|
||||
CreateFreightSync(
|
||||
@@ -135,6 +137,11 @@ type CreateFreightSyncInput struct {
|
||||
SyncToNow bool
|
||||
}
|
||||
|
||||
type DeleteFreightOrderInput struct {
|
||||
ActorUserID string
|
||||
OrderID string
|
||||
}
|
||||
|
||||
type FreightOrder struct {
|
||||
ID string
|
||||
ExternalStockID string
|
||||
|
||||
@@ -192,6 +192,27 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) DeleteFreightOrder(
|
||||
ctx context.Context,
|
||||
input DeleteFreightOrderInput,
|
||||
) error {
|
||||
if adapter.freight == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
err := adapter.freight.DeleteOrder(
|
||||
ctx,
|
||||
usecase.DeleteFreightOrderCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
ActorUserID: input.ActorUserID,
|
||||
OrderID: input.OrderID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return mapUsecaseError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateProcurementRequest(
|
||||
ctx context.Context,
|
||||
input CreateProcurementRequestInput,
|
||||
@@ -933,6 +954,10 @@ func mapUsecaseError(err error) error {
|
||||
return err
|
||||
}
|
||||
var public error
|
||||
if typed.Code == "FREIGHT_ORDER_IN_USE" {
|
||||
public = ErrFreightOrderInUse
|
||||
return &adapterError{public: public, cause: err}
|
||||
}
|
||||
switch typed.Kind {
|
||||
case usecase.ErrorKindInvalid:
|
||||
if typed.Code == "TASK_CANCEL_INVALID" {
|
||||
|
||||
Reference in New Issue
Block a user