diff --git a/backend-api/internal/repository/sqlite/freight_delete_repository_test.go b/backend-api/internal/repository/sqlite/freight_delete_repository_test.go index 3c8cc69..404e7c8 100644 --- a/backend-api/internal/repository/sqlite/freight_delete_repository_test.go +++ b/backend-api/internal/repository/sqlite/freight_delete_repository_test.go @@ -140,22 +140,125 @@ func TestDeleteFreightOrderIsScopedIdempotentAndReimportable(t *testing.T) { } } -func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically( +func TestDeleteFreightOrderRemovesUnprotectedProcurementDrafts( + t *testing.T, +) { + for index, status := range []domain.ProcurementRequestStatus{ + domain.ProcurementBlocked, + domain.ProcurementNeedsImage, + domain.ProcurementSourceChanged, + } { + t.Run(string(status), func(t *testing.T) { + db := openDatabase(t) + store, _ := repository.New(db) + ctx := context.Background() + now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) + userID := uuid(2430 + index*10) + seedFreightUser(t, db, userID, now) + + run := freightRun(2431+index*10, userID, now) + createAndStartFreightRun( + t, + store, + run, + "delete-draft-"+string(status), + "4", + ) + if err := store.CompleteFreightSync( + ctx, + run, + freightBatch(2432+index*10, "a", "b"), + now.Add(time.Second), + ); err != nil { + t.Fatalf("CompleteFreightSync() error = %v", err) + } + orders, _ := store.ListFreightOrders( + ctx, + "local-admin", + 10, + ) + detail, _ := store.GetFreightOrder( + ctx, + "local-admin", + orders[0].ID, + ) + procurement, err := usecase.NewProcurementService( + store, + procurementClock{now: now.Add(2 * time.Second)}, + &procurementIDs{next: 2440 + index*10}, + ) + if err != nil { + t.Fatalf("NewProcurementService() error = %v", err) + } + result, err := procurement.CreateRequest( + ctx, + usecase.CreateProcurementRequestCommand{ + CreatorSubject: "local-admin", + ActorUserID: userID, + FreightOrderItemID: detail.Items[0].ID, + ConfirmProcurementNeeded: true, + }, + ) + if err != nil { + t.Fatalf("CreateRequest() error = %v", err) + } + if status != domain.ProcurementNeedsImage { + blockingCode := "TITLE_REQUIRED" + if status == domain.ProcurementSourceChanged { + blockingCode = "SOURCE_CHANGED" + } + if _, err := db.Exec( + `UPDATE procurement_requests + SET status = ?, blocking_code = ? + WHERE id = ?`, + status, + blockingCode, + result.Request.ID, + ); err != nil { + t.Fatalf("set draft status %s: %v", status, err) + } + } + + keys, err := store.DeleteFreightOrder( + ctx, + "local-admin", + orders[0].ID, + ) + if err != nil || len(keys) != 0 { + t.Fatalf("draft delete = %#v, %v", keys, err) + } + for table, want := range map[string]int{ + "freight_orders": 0, + "freight_order_items": 0, + "procurement_requests": 0, + } { + var count int + if err := db.QueryRow( + "SELECT count(*) FROM " + table, + ).Scan(&count); err != nil || count != want { + t.Fatalf("%s count = %d, %v", table, count, err) + } + } + }) + } +} + +func TestDeleteFreightOrderProtectsReferenceAndTaskAtomically( t *testing.T, ) { db := openDatabase(t) store, _ := repository.New(db) ctx := context.Background() - now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC) - userID := uuid(2430) + now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + userID := uuid(2470) seedFreightUser(t, db, userID, now) - run := freightRun(2431, userID, now) - createAndStartFreightRun(t, store, run, "delete-conflict", "4") + run := freightRun(2471, userID, now) + createAndStartFreightRun(t, store, run, "delete-protected", "5") if err := store.CompleteFreightSync( ctx, run, - freightBatch(2432, "a", "b"), + freightBatch(2472, "a", "b"), now.Add(time.Second), ); err != nil { t.Fatalf("CompleteFreightSync() error = %v", err) @@ -169,12 +272,12 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically( procurement, err := usecase.NewProcurementService( store, procurementClock{now: now.Add(2 * time.Second)}, - &procurementIDs{next: 2440}, + &procurementIDs{next: 2480}, ) if err != nil { t.Fatalf("NewProcurementService() error = %v", err) } - if _, err := procurement.CreateRequest( + result, err := procurement.CreateRequest( ctx, usecase.CreateProcurementRequestCommand{ CreatorSubject: "local-admin", @@ -182,22 +285,75 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically( FreightOrderItemID: detail.Items[0].ID, ConfirmProcurementNeeded: true, }, - ); err != nil { + ) + if err != nil { t.Fatalf("CreateRequest() error = %v", err) } + asset := domain.Asset{ + ID: uuid(2490), + CreatorSubject: "local-admin", + Purpose: domain.AssetPurposeTaskReference, + MediaType: domain.NormalizedImageMediaType, + SizeBytes: 100, + SHA256: repeatHex("f"), + StorageKey: "freight-delete/reference.jpg", + CreatedAt: now, + } + if _, _, err := store.CreateAssetIdempotent( + ctx, + asset, + "freight-delete-reference", + repeatHex("e"), + ); err != nil { + t.Fatalf("CreateAssetIdempotent() error = %v", err) + } + if _, err := procurement.BindReference( + ctx, + usecase.BindProcurementReferenceCommand{ + CreatorSubject: "local-admin", + ActorUserID: userID, + RequestID: result.Request.ID, + ImageAssetID: asset.ID, + }, + ); err != nil { + t.Fatalf("BindReference() error = %v", err) + } keys, err := store.DeleteFreightOrder( ctx, "local-admin", orders[0].ID, ) - if !errors.Is(err, usecase.ErrFreightOrderInUse) || len(keys) != 0 { - t.Fatalf("conflicting delete = %#v, %v", keys, err) + if !errors.Is(err, usecase.ErrFreightOrderHasReference) || + len(keys) != 0 { + t.Fatalf("reference delete = %#v, %v", keys, err) + } + if _, err := procurement.CreateTask( + ctx, + usecase.CreateProcurementTaskCommand{ + CreatorSubject: "local-admin", + ActorUserID: userID, + RequestID: result.Request.ID, + IdempotencyKey: "freight-delete-task", + }, + ); err != nil { + t.Fatalf("CreateTask() error = %v", err) + } + keys, err = store.DeleteFreightOrder( + ctx, + "local-admin", + orders[0].ID, + ) + if !errors.Is(err, usecase.ErrFreightOrderHasPurchaseTask) || + len(keys) != 0 { + t.Fatalf("task delete = %#v, %v", keys, err) } for table, want := range map[string]int{ - "freight_orders": 1, - "freight_order_items": 2, - "procurement_requests": 1, + "freight_orders": 1, + "freight_order_items": 2, + "procurement_requests": 1, + "purchase_tasks": 1, + "purchase_task_sources": 1, } { var count int if err := db.QueryRow("SELECT count(*) FROM " + table).Scan( diff --git a/backend-api/internal/repository/sqlite/freight_repository.go b/backend-api/internal/repository/sqlite/freight_repository.go index 6b20172..d05ca4b 100644 --- a/backend-api/internal/repository/sqlite/freight_repository.go +++ b/backend-api/internal/repository/sqlite/freight_repository.go @@ -642,7 +642,63 @@ func (store *Store) DeleteFreightOrder( return nil, repositoryFailure(err) } - var inUse int + var hasPurchaseTask int + err = tx.QueryRowContext( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM freight_order_items AS item + JOIN procurement_requests AS request + ON request.freight_order_item_id = item.id + WHERE item.freight_order_id = ? + AND request.purchase_task_id IS NOT NULL + )`, + storedOrderID, + ).Scan(&hasPurchaseTask) + if err != nil { + return nil, repositoryFailure(err) + } + if hasPurchaseTask != 0 { + return nil, usecase.ErrFreightOrderHasPurchaseTask + } + + var hasReference int + err = tx.QueryRowContext( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM freight_order_items AS item + JOIN procurement_requests AS request + ON request.freight_order_item_id = item.id + WHERE item.freight_order_id = ? + AND request.reference_asset_id IS NOT NULL + )`, + storedOrderID, + ).Scan(&hasReference) + if err != nil { + return nil, repositoryFailure(err) + } + if hasReference != 0 { + return nil, usecase.ErrFreightOrderHasReference + } + + if _, err := tx.ExecContext( + ctx, + `DELETE FROM procurement_requests + WHERE freight_order_item_id IN ( + SELECT id + FROM freight_order_items + WHERE freight_order_id = ? + ) + AND reference_asset_id IS NULL + AND purchase_task_id IS NULL + AND status IN ('BLOCKED', 'NEEDS_IMAGE', 'SOURCE_CHANGED')`, + storedOrderID, + ); err != nil { + return nil, repositoryFailure(err) + } + + var remainingRequests int err = tx.QueryRowContext( ctx, `SELECT EXISTS ( @@ -653,12 +709,12 @@ func (store *Store) DeleteFreightOrder( WHERE item.freight_order_id = ? )`, storedOrderID, - ).Scan(&inUse) + ).Scan(&remainingRequests) if err != nil { return nil, repositoryFailure(err) } - if inUse != 0 { - return nil, usecase.ErrFreightOrderInUse + if remainingRequests != 0 { + return nil, repositoryFailure(usecase.ErrRepositoryInvariant) } rows, err := tx.QueryContext( diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 8c7b3e3..8f15f9c 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -773,26 +773,6 @@ 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"` @@ -829,6 +809,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) { !strings.Contains(bind.Body.String(), `"status":"READY"`) { t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body) } + deleteWithReference := performAdminRequest( + t, + fixture.router, + http.MethodDelete, + "/api/v1/freight-orders/"+orderBody.Items[0].ID, + "", + nil, + "", + ) + if deleteWithReference.Code != http.StatusConflict || + !strings.Contains( + deleteWithReference.Body.String(), + `"code":"FREIGHT_ORDER_HAS_REFERENCE"`, + ) { + t.Fatalf( + "delete with reference status/body = %d / %s", + deleteWithReference.Code, + deleteWithReference.Body, + ) + } createTask := performAdminRequest( t, fixture.router, @@ -847,6 +847,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) { createTask.Body, ) } + deleteWithTask := performAdminRequest( + t, + fixture.router, + http.MethodDelete, + "/api/v1/freight-orders/"+orderBody.Items[0].ID, + "", + nil, + "", + ) + if deleteWithTask.Code != http.StatusConflict || + !strings.Contains( + deleteWithTask.Body.String(), + `"code":"FREIGHT_ORDER_HAS_PURCHASE_TASK"`, + ) { + t.Fatalf( + "delete with task status/body = %d / %s", + deleteWithTask.Code, + deleteWithTask.Body, + ) + } var taskBody struct { Task struct { ID string `json:"id"` @@ -899,6 +919,90 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) { } } +func TestAdminFreightDeleteRemovesNeedsImageDraft(t *testing.T) { + fixture := newAdminIntegrationFixture(t) + createSync := performAdminRequest( + t, + fixture.router, + http.MethodPost, + "/api/v1/freight-syncs", + "application/json", + strings.NewReader( + `{"mode":"ORDER_NUMBER","order_number":"DRAFT-DELETE"}`, + ), + "draft-delete-freight-sync", + ) + requireAdminStatus(t, createSync, http.StatusCreated) + orders := performAdminRequest( + t, + fixture.router, + http.MethodGet, + "/api/v1/freight-orders", + "", + nil, + "", + ) + var orderBody struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + decodeResponse(t, orders, &orderBody) + detail := performAdminRequest( + t, + fixture.router, + http.MethodGet, + "/api/v1/freight-orders/"+orderBody.Items[0].ID, + "", + nil, + "", + ) + var detailBody struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + decodeResponse(t, detail, &detailBody) + createRequest := performAdminRequest( + t, + fixture.router, + http.MethodPost, + "/api/v1/freight-items/"+detailBody.Items[0].ID+ + "/procurement-request", + "application/json", + strings.NewReader(`{"confirm_procurement_needed":true}`), + "", + ) + if createRequest.Code != http.StatusCreated || + !strings.Contains(createRequest.Body.String(), `"status":"NEEDS_IMAGE"`) { + t.Fatalf( + "create request status/body = %d / %s", + createRequest.Code, + createRequest.Body, + ) + } + deleteDraft := performAdminRequest( + t, + fixture.router, + http.MethodDelete, + "/api/v1/freight-orders/"+orderBody.Items[0].ID, + "", + nil, + "", + ) + requireAdminStatus(t, deleteDraft, http.StatusNoContent) + deletedDetail := performAdminRequest( + t, + fixture.router, + http.MethodGet, + "/api/v1/freight-orders/"+orderBody.Items[0].ID, + "", + nil, + "", + ) + requireAdminStatus(t, deletedDetail, http.StatusNotFound) +} + func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) { fixture := newAdminIntegrationFixture(t) taskID, executionID, taskHash, firstKey, secondKey := diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index 31ab968..4086ad8 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -561,10 +561,17 @@ func (h *Handler) DeleteFreightOrder(ctx *gin.Context) { OrderID: orderID, }, ) - if errors.Is(err, ErrFreightOrderInUse) { + if errors.Is(err, ErrFreightOrderHasPurchaseTask) { ctx.Redirect( http.StatusSeeOther, - "/freight/"+pathEscape(orderID)+"?notice=delete-conflict", + "/freight/"+pathEscape(orderID)+"?notice=delete-task-conflict", + ) + return + } + if errors.Is(err, ErrFreightOrderHasReference) { + ctx.Redirect( + http.StatusSeeOther, + "/freight/"+pathEscape(orderID)+"?notice=delete-reference-conflict", ) return } @@ -720,8 +727,10 @@ func freightNotice(value string) string { return "ERP 货运单已同步,货运信息和商品明细已更新。" case "deleted": return "货运单已从本系统删除,不影响顺运宝 ERP。" - case "delete-conflict": - return "该货运单已进入采购流程,不能删除。" + case "delete-task-conflict": + return "该货运单已生成采购任务,不能删除。" + case "delete-reference-conflict": + return "该货运单已绑定采购参考图,不能删除。" default: return "" } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 6244205..55a2ecf 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -1330,7 +1330,11 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) { ) if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "删除本地货运单") || - !strings.Contains(detail.Body.String(), "data-freight-delete-dialog") { + !strings.Contains(detail.Body.String(), "data-freight-delete-dialog") || + !strings.Contains( + detail.Body.String(), + "未绑定参考图的采购需求草稿", + ) { t.Fatalf("delete controls status/body = %d / %s", detail.Code, detail.Body) } cookie := csrfCookie(t, detail) @@ -1374,7 +1378,7 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) { ) } - service.deleteErr = ErrFreightOrderInUse + service.deleteErr = ErrFreightOrderHasReference request = httptest.NewRequest( http.MethodPost, "/freight/"+testTaskID+"/delete", @@ -1386,10 +1390,32 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) { router.ServeHTTP(response, request) if response.Code != http.StatusSeeOther || response.Header().Get("Location") != - "/freight/"+testTaskID+"?notice=delete-conflict" || + "/freight/"+testTaskID+"?notice=delete-reference-conflict" || len(logged) != 1 { t.Fatalf( - "conflicting delete status/location/log = %d / %q / %#v", + "reference conflict status/location/log = %d / %q / %#v", + response.Code, + response.Header().Get("Location"), + logged, + ) + } + + service.deleteErr = ErrFreightOrderHasPurchaseTask + 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-task-conflict" || + len(logged) != 1 { + t.Fatalf( + "task conflict status/location/log = %d / %q / %#v", response.Code, response.Header().Get("Location"), logged, diff --git a/backend-api/internal/transport/webui/templates/freight-detail.gohtml b/backend-api/internal/transport/webui/templates/freight-detail.gohtml index 6906522..d55d5a7 100644 --- a/backend-api/internal/transport/webui/templates/freight-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-detail.gohtml @@ -137,7 +137,7 @@