diff --git a/backend-api/internal/repository/sqlite/freight_delete_repository_test.go b/backend-api/internal/repository/sqlite/freight_delete_repository_test.go new file mode 100644 index 0000000..3c8cc69 --- /dev/null +++ b/backend-api/internal/repository/sqlite/freight_delete_repository_test.go @@ -0,0 +1,209 @@ +package sqlite_test + +import ( + "context" + "errors" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" + repository "cmroubao/backend-api/internal/repository/sqlite" + "cmroubao/backend-api/internal/usecase" +) + +func TestDeleteFreightOrderIsScopedIdempotentAndReimportable(t *testing.T) { + db := openDatabase(t) + store, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New() error = %v", err) + } + ctx := context.Background() + now := time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC) + userID := uuid(2420) + seedFreightUser(t, db, userID, now) + + first := freightRun(2421, userID, now) + createAndStartFreightRun(t, store, first, "delete-source-1", "1") + batch := freightBatch(2422, "a", "b") + thumb := "190846474" + batch.Orders[0].Items[0].ProductThumbRef = &thumb + if err := store.CompleteFreightSync( + ctx, + first, + batch, + now.Add(time.Second), + ); err != nil { + t.Fatalf("CompleteFreightSync() error = %v", err) + } + orders, err := store.ListFreightOrders(ctx, "local-admin", 10) + if err != nil || len(orders) != 1 { + t.Fatalf("orders = %+v, %v", orders, err) + } + orderID := orders[0].ID + detail, err := store.GetFreightOrder(ctx, "local-admin", orderID) + if err != nil || len(detail.Items) != 2 { + t.Fatalf("detail = %+v, %v", detail, err) + } + readyImage := domain.FreightItemImage{ + CreatorSubject: "local-admin", + FreightOrderItemID: detail.Items[0].ID, + ProductThumbRef: thumb, + Status: domain.FreightItemImageReady, + MediaType: "image/jpeg", + SizeBytes: 100, + SHA256: repeatHex("c"), + StorageKey: "freight/delete-ready.jpg", + UpdatedAt: now.Add(2 * time.Second), + } + if _, err := store.SaveFreightItemImage(ctx, readyImage); err != nil { + t.Fatalf("SaveFreightItemImage() error = %v", err) + } + + watermarkThrough := now.Add(30 * time.Minute) + dateRun := freightDateRun( + 2423, + userID, + now.Add(time.Minute), + "2026-07-29", + "2026-07-29", + watermarkThrough, + ) + createAndStartFreightRun(t, store, dateRun, "delete-watermark", "2") + if err := store.CompleteFreightDateSync( + ctx, + dateRun, + domain.FreightImportBatch{}, + watermarkThrough, + now.Add(2*time.Minute), + ); err != nil { + t.Fatalf("CompleteFreightDateSync() error = %v", err) + } + + keys, err := store.DeleteFreightOrder( + ctx, + "another-admin", + orderID, + ) + if err != nil || len(keys) != 0 { + t.Fatalf("cross-subject delete = %#v, %v", keys, err) + } + if _, err := store.GetFreightOrder( + ctx, + "local-admin", + orderID, + ); err != nil { + t.Fatalf("cross-subject delete changed order: %v", err) + } + + keys, err = store.DeleteFreightOrder(ctx, "local-admin", orderID) + if err != nil || len(keys) != 1 || + keys[0] != readyImage.StorageKey { + t.Fatalf("DeleteFreightOrder() = %#v, %v", keys, err) + } + for table, want := range map[string]int{ + "freight_orders": 0, + "freight_order_items": 0, + "freight_item_images": 0, + "erp_sync_runs": 2, + } { + 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) + } + } + watermark, err := store.GetFreightSyncWatermark(ctx, "local-admin") + if err != nil || watermark == nil || + !watermark.LastSuccessfulTo.Equal(watermarkThrough) { + t.Fatalf("watermark after delete = %+v, %v", watermark, err) + } + keys, err = store.DeleteFreightOrder(ctx, "local-admin", orderID) + if err != nil || len(keys) != 0 { + t.Fatalf("repeated delete = %#v, %v", keys, err) + } + + reimport := freightRun(2424, userID, now.Add(3*time.Minute)) + createAndStartFreightRun(t, store, reimport, "delete-reimport", "3") + if err := store.CompleteFreightSync( + ctx, + reimport, + freightBatch(2425, "a", "b"), + now.Add(4*time.Minute), + ); err != nil { + t.Fatalf("reimport error = %v", err) + } + orders, err = store.ListFreightOrders(ctx, "local-admin", 10) + if err != nil || len(orders) != 1 || orders[0].ID == orderID || + orders[0].Revision != 1 { + t.Fatalf("reimported orders = %+v, %v", orders, err) + } +} + +func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically( + 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) + seedFreightUser(t, db, userID, now) + + run := freightRun(2431, userID, now) + createAndStartFreightRun(t, store, run, "delete-conflict", "4") + if err := store.CompleteFreightSync( + ctx, + run, + freightBatch(2432, "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}, + ) + if err != nil { + t.Fatalf("NewProcurementService() error = %v", err) + } + if _, err := procurement.CreateRequest( + ctx, + usecase.CreateProcurementRequestCommand{ + CreatorSubject: "local-admin", + ActorUserID: userID, + FreightOrderItemID: detail.Items[0].ID, + ConfirmProcurementNeeded: true, + }, + ); err != nil { + t.Fatalf("CreateRequest() 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) + } + for table, want := range map[string]int{ + "freight_orders": 1, + "freight_order_items": 2, + "procurement_requests": 1, + } { + 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) + } + } +} diff --git a/backend-api/internal/repository/sqlite/freight_repository.go b/backend-api/internal/repository/sqlite/freight_repository.go index d73e0f4..6b20172 100644 --- a/backend-api/internal/repository/sqlite/freight_repository.go +++ b/backend-api/internal/repository/sqlite/freight_repository.go @@ -617,6 +617,112 @@ func (store *Store) GetFreightOrder( return domain.FreightOrderDetail{Order: order, Items: items}, nil } +func (store *Store) DeleteFreightOrder( + ctx context.Context, + creatorSubject, orderID string, +) ([]string, error) { + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return nil, repositoryFailure(err) + } + defer tx.Rollback() + + var storedOrderID string + err = tx.QueryRowContext( + ctx, + `SELECT id FROM freight_orders + WHERE creator_subject = ? AND id = ?`, + creatorSubject, + orderID, + ).Scan(&storedOrderID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, repositoryFailure(err) + } + + var inUse 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 = ? + )`, + storedOrderID, + ).Scan(&inUse) + if err != nil { + return nil, repositoryFailure(err) + } + if inUse != 0 { + return nil, usecase.ErrFreightOrderInUse + } + + rows, err := tx.QueryContext( + ctx, + `SELECT image.storage_key + FROM freight_item_images AS image + JOIN freight_order_items AS item + ON item.id = image.freight_order_item_id + WHERE item.freight_order_id = ? + AND image.status = 'READY' + AND image.storage_key IS NOT NULL + ORDER BY image.storage_key`, + storedOrderID, + ) + if err != nil { + return nil, repositoryFailure(err) + } + storageKeys := make([]string, 0) + for rows.Next() { + var storageKey string + if err := rows.Scan(&storageKey); err != nil { + rows.Close() + return nil, repositoryFailure(err) + } + storageKeys = append(storageKeys, storageKey) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, repositoryFailure(err) + } + if err := rows.Close(); err != nil { + return nil, repositoryFailure(err) + } + + if _, err := tx.ExecContext( + ctx, + `DELETE FROM freight_order_items WHERE freight_order_id = ?`, + storedOrderID, + ); err != nil { + return nil, repositoryFailure(err) + } + result, err := tx.ExecContext( + ctx, + `DELETE FROM freight_orders + WHERE creator_subject = ? AND id = ?`, + creatorSubject, + storedOrderID, + ) + if err != nil { + return nil, repositoryFailure(err) + } + deleted, err := result.RowsAffected() + if err != nil { + return nil, repositoryFailure(err) + } + if deleted != 1 { + return nil, repositoryFailure(usecase.ErrRepositoryInvariant) + } + if err := tx.Commit(); err != nil { + return nil, repositoryFailure(err) + } + return storageKeys, nil +} + const freightSyncSelect = `SELECT id, creator_subject, created_by_user_id, mode, order_number, created_from, created_to, watermark_through, query_sha256, status, diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index eb26117..ceadbae 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -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( diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index d53f9b5..8c7b3e3 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -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"` diff --git a/backend-api/internal/transport/httpapi/freight_handlers.go b/backend-api/internal/transport/httpapi/freight_handlers.go index e1e5c63..f3ede97 100644 --- a/backend-api/internal/transport/httpapi/freight_handlers.go +++ b/backend-api/internal/transport/httpapi/freight_handlers.go @@ -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, diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index a290783..31ab968 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -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 "" } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index a00a26e..6244205 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -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, diff --git a/backend-api/internal/transport/webui/static/admin.js b/backend-api/internal/transport/webui/static/admin.js index 3b8c6d1..ceeb6cc 100644 --- a/backend-api/internal/transport/webui/static/admin.js +++ b/backend-api/internal/transport/webui/static/admin.js @@ -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(); + }); + } })(); diff --git a/backend-api/internal/transport/webui/templates/freight-detail.gohtml b/backend-api/internal/transport/webui/templates/freight-detail.gohtml index 8d25a02..6906522 100644 --- a/backend-api/internal/transport/webui/templates/freight-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-detail.gohtml @@ -13,7 +13,11 @@

{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}

ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}

- 返回列表 +
+ 返回列表 + +
{{if .Notice}}
{{.Notice}}
{{end}}
@@ -129,6 +133,38 @@

该货运单没有商品明细

{{end}}
+ + + + +

删除本地货运单?

+

将删除本系统保存的 {{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}该货运单{{end}} 及商品图片,不影响顺运宝 ERP。已进入采购流程时系统会拒绝删除。

+
+ +
+ + + +
+
+
diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index 0cb73e1..9e1477c 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -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 diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index 660df86..a0141dd 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -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" { diff --git a/backend-api/internal/usecase/errors.go b/backend-api/internal/usecase/errors.go index a0140af..2f03437 100644 --- a/backend-api/internal/usecase/errors.go +++ b/backend-api/internal/usecase/errors.go @@ -65,6 +65,7 @@ var ( ErrSourceReferenceConflict = errors.New("source reference conflict") ErrAssetUnavailable = errors.New("asset is unavailable") ErrTaskStateConflict = errors.New("task state conflict") + ErrFreightOrderInUse = errors.New("freight order is in use") ErrRepositoryUnavailable = errors.New("repository unavailable") ErrRepositoryInvariant = errors.New("repository invariant failed") ) @@ -106,6 +107,13 @@ func wrapRepositoryError(err error) error { "task state does not allow this operation", err, ) + case errors.Is(err, ErrFreightOrderInUse): + return newError( + ErrorKindConflict, + "FREIGHT_ORDER_IN_USE", + "freight order is already used by procurement", + err, + ) case errors.Is(err, ErrRepositoryUnavailable): result := newError( ErrorKindUnavailable, diff --git a/backend-api/internal/usecase/freight_image_ports.go b/backend-api/internal/usecase/freight_image_ports.go index e67780f..9ecf247 100644 --- a/backend-api/internal/usecase/freight_image_ports.go +++ b/backend-api/internal/usecase/freight_image_ports.go @@ -39,4 +39,5 @@ type FreightImageRepository interface { type FreightImageCache interface { CacheRun(context.Context, string, string) error + DeleteStoredImages([]string) } diff --git a/backend-api/internal/usecase/freight_image_service.go b/backend-api/internal/usecase/freight_image_service.go index eab52dc..f33cbf6 100644 --- a/backend-api/internal/usecase/freight_image_service.go +++ b/backend-api/internal/usecase/freight_image_service.go @@ -169,6 +169,20 @@ func (service *FreightImageService) deleteStoredImage(storageKey string) { _ = service.store.Delete(ctx, storageKey) } +func (service *FreightImageService) DeleteStoredImages(storageKeys []string) { + if len(storageKeys) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), freightImageCleanup) + defer cancel() + for _, storageKey := range storageKeys { + if ctx.Err() != nil { + return + } + _ = service.store.Delete(ctx, storageKey) + } +} + func (service *FreightImageService) OpenItemImage( ctx context.Context, creatorSubject, itemID string, diff --git a/backend-api/internal/usecase/freight_image_service_test.go b/backend-api/internal/usecase/freight_image_service_test.go index 2644fbc..a73515c 100644 --- a/backend-api/internal/usecase/freight_image_service_test.go +++ b/backend-api/internal/usecase/freight_image_service_test.go @@ -118,6 +118,30 @@ func TestFreightImageOpenDoesNotRevealUnavailableItems(t *testing.T) { } } +func TestFreightImageDeleteStoredImagesUsesIdempotentStoreCleanup(t *testing.T) { + store := &freightImageStoreFake{} + service, err := NewFreightImageService( + &freightImageRepositoryFake{}, + &freightImageSourceFake{}, + store, + fakeClock{}, + ) + if err != nil { + t.Fatalf("NewFreightImageService() error = %v", err) + } + service.DeleteStoredImages([]string{ + "first/image.jpg", + "second/image.jpg", + }) + store.mu.Lock() + defer store.mu.Unlock() + if len(store.deleted) != 2 || + store.deleted[0] != "first/image.jpg" || + store.deleted[1] != "second/image.jpg" { + t.Fatalf("deleted storage keys = %#v", store.deleted) + } +} + type freightImageRepositoryFake struct { mu sync.Mutex jobs []domain.FreightItemImageJob diff --git a/backend-api/internal/usecase/freight_ports.go b/backend-api/internal/usecase/freight_ports.go index 3946bd0..f0faf7e 100644 --- a/backend-api/internal/usecase/freight_ports.go +++ b/backend-api/internal/usecase/freight_ports.go @@ -58,4 +58,9 @@ type FreightRepository interface { string, string, ) (domain.FreightOrderDetail, error) + DeleteFreightOrder( + context.Context, + string, + string, + ) ([]string, error) } diff --git a/backend-api/internal/usecase/freight_service.go b/backend-api/internal/usecase/freight_service.go index 91dcffa..84e2349 100644 --- a/backend-api/internal/usecase/freight_service.go +++ b/backend-api/internal/usecase/freight_service.go @@ -69,6 +69,12 @@ type CreateFreightDateSyncCommand struct { SyncToNow bool } +type DeleteFreightOrderCommand struct { + CreatorSubject string + ActorUserID string + OrderID string +} + type CreateFreightSyncResult struct { Run domain.FreightSyncRun Replayed bool @@ -701,6 +707,44 @@ func (service *FreightService) GetOrder( return detail, nil } +func (service *FreightService) DeleteOrder( + ctx context.Context, + command DeleteFreightOrderCommand, +) error { + command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) + command.ActorUserID = strings.TrimSpace(command.ActorUserID) + command.OrderID = strings.TrimSpace(command.OrderID) + fields := map[string]string{} + if command.CreatorSubject == "" { + fields["creator_subject"] = "is required" + } + if !isUUID(command.ActorUserID) { + fields["actor_user_id"] = "must be a UUID" + } + if !isUUID(command.OrderID) { + fields["order_id"] = "must be a UUID" + } + if len(fields) > 0 { + return invalidError( + "FREIGHT_DELETE_INVALID", + "freight order deletion is invalid", + fields, + ) + } + storageKeys, err := service.repository.DeleteFreightOrder( + ctx, + command.CreatorSubject, + command.OrderID, + ) + if err != nil { + return wrapRepositoryError(err) + } + if service.imageCache != nil { + service.imageCache.DeleteStoredImages(storageKeys) + } + return nil +} + func (service *FreightService) normalize( source domain.FreightSourceBatch, ) (domain.FreightImportBatch, error) { diff --git a/backend-api/internal/usecase/freight_service_test.go b/backend-api/internal/usecase/freight_service_test.go index 443b889..0f0d9e2 100644 --- a/backend-api/internal/usecase/freight_service_test.go +++ b/backend-api/internal/usecase/freight_service_test.go @@ -236,6 +236,7 @@ type recordingFreightImageCache struct { creatorSubject string runID string err error + deleted []string } func (cache *recordingFreightImageCache) CacheRun( @@ -248,6 +249,12 @@ func (cache *recordingFreightImageCache) CacheRun( return cache.err } +func (cache *recordingFreightImageCache) DeleteStoredImages( + storageKeys []string, +) { + cache.deleted = append(cache.deleted, storageKeys...) +} + func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) { repository := &syncTrackingRepository{} source := &blockingOrderSource{} @@ -378,6 +385,56 @@ func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncComman } } +func TestDeleteFreightOrderValidatesConflictAndCleansImages(t *testing.T) { + repository := &syncTrackingRepository{ + deleteStorageKeys: []string{"first/image.jpg", "second/image.jpg"}, + } + imageCache := &recordingFreightImageCache{} + service, err := NewFreightService( + repository, + &recordingDateSource{}, + fakeClock{}, + &sequenceIDs{}, + time.Minute, + WithFreightImageCache(imageCache), + ) + if err != nil { + t.Fatalf("NewFreightService() error = %v", err) + } + command := DeleteFreightOrderCommand{ + CreatorSubject: " local-admin ", + ActorUserID: "00000000-0000-4000-8000-000000000099", + OrderID: "00000000-0000-4000-8000-000000000100", + } + if err := service.DeleteOrder(context.Background(), command); err != nil { + t.Fatalf("DeleteOrder() error = %v", err) + } + if repository.deleteCalls != 1 || + repository.deleteCreatorSubject != "local-admin" || + repository.deleteOrderID != command.OrderID { + t.Fatalf("delete repository call = %+v", repository) + } + if len(imageCache.deleted) != 2 || + imageCache.deleted[0] != "first/image.jpg" || + imageCache.deleted[1] != "second/image.jpg" { + t.Fatalf("deleted image keys = %#v", imageCache.deleted) + } + + repository.deleteErr = ErrFreightOrderInUse + err = service.DeleteOrder(context.Background(), command) + assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_ORDER_IN_USE") + if len(imageCache.deleted) != 2 { + t.Fatalf("conflicting delete cleaned images = %#v", imageCache.deleted) + } + + command.OrderID = "invalid" + err = service.DeleteOrder(context.Background(), command) + assertUsecaseError(t, err, ErrorKindInvalid, "FREIGHT_DELETE_INVALID") + if repository.deleteCalls != 2 { + t.Fatalf("invalid delete reached repository %d times", repository.deleteCalls) + } +} + func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) { source := &recordingDateSource{} service := &FreightService{source: source} @@ -581,12 +638,17 @@ func (*blockingOrderSource) QueryCreatedRange( } type syncTrackingRepository struct { - mu sync.Mutex - startCalls int - completeCalls int - failCalls int - failCode string - failContextErr error + mu sync.Mutex + startCalls int + completeCalls int + failCalls int + failCode string + failContextErr error + deleteCalls int + deleteCreatorSubject string + deleteOrderID string + deleteStorageKeys []string + deleteErr error } func (*syncTrackingRepository) CreateFreightSync( @@ -682,6 +744,17 @@ func (*syncTrackingRepository) GetFreightOrder( return domain.FreightOrderDetail{}, nil } +func (repository *syncTrackingRepository) DeleteFreightOrder( + _ context.Context, + creatorSubject, orderID string, +) ([]string, error) { + repository.deleteCalls++ + repository.deleteCreatorSubject = creatorSubject + repository.deleteOrderID = orderID + return append([]string(nil), repository.deleteStorageKeys...), + repository.deleteErr +} + func (source *recordingDateSource) EnsureAuthenticated(context.Context) error { source.ensureCalls++ return source.ensureErr @@ -800,3 +873,11 @@ func (*dateCaptureRepository) GetFreightOrder( ) (domain.FreightOrderDetail, error) { return domain.FreightOrderDetail{}, nil } + +func (*dateCaptureRepository) DeleteFreightOrder( + context.Context, + string, + string, +) ([]string, error) { + return nil, nil +} diff --git a/docs/current-state.md b/docs/current-state.md index 1d259f1..4135558 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-241 已完成 ERP 货运列表与商品详情展示优化 +- 阶段:T-242 已完成受约束的本地货运单删除 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -21,7 +21,9 @@ `FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情 `created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存 `productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权 - 本地路由、货运列表和详情中显示图片、状态与采购字段。 + 本地路由、货运列表和详情中显示图片、状态与采购字段。T-242 允许 ADMIN 从详情页删除 + 尚未进入采购流程的本地货运快照及缓存图;已有采购引用时原子拒绝,不修改 ERP、同步 + 运行历史或增量水位,完整订单号可重新导入。 - ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、 单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226 已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的 @@ -43,7 +45,7 @@ - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过; Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110 -- 后端测试:T-226 至 T-241 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` +- 后端测试:T-226 至 T-242 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、 详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准 验证也通过,均未访问真实 ERP; @@ -59,6 +61,9 @@ 总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、 图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、 破图、遮挡和控制台错误。 +- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE; + 删除事务覆盖全部历史商品并阻止任何采购引用,提交后以有界预算清理缓存 JPEG。真实 + 隔离服务已验证成功后列表为空、图片 404 和文件消失;顺运宝 ERP 未被调用。 - 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret 均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App 登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停 @@ -210,6 +215,7 @@ | `docs/tasks/T-239.md` | DONE | 冻结货运规格 SKU、TWD 原始单价和图片引用契约 | | `docs/tasks/T-240.md` | DONE | 后端受控缓存并鉴权提供货运商品图片 | | `docs/tasks/T-241.md` | DONE | 优化货运列表代表图与商品采购信息展示 | +| `docs/tasks/T-242.md` | DONE | 受约束删除本地货运快照并保护采购审计 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -221,13 +227,14 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-241 ERP 契约、货运存储、采购需求生成、日期增量同步、Go +- 已完成:另含 T-220 至 T-242 ERP 契约、货运存储、采购需求生成、日期增量同步、Go 直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector - 清理、受控本地凭证加载、同步单号导入、分钟时间兼容、货运商品元数据契约、本地图片缓存 - 和货运管理展示优化。 + 清理、受控本地凭证加载、同步单号导入、分钟时间兼容、货运商品元数据契约、本地图片缓存、 + 货运管理展示优化和受约束本地删除。 - 进行中:无。 -- 下一步:用真实 ERP 账号受控导入一个测试货运单,复核真实图片响应、缓存状态、规格、 - TWD 原始单价和重新导入幂等性;不把真实数据或诊断响应提交到仓库。 +- 下一步:用真实 ERP 账号受控导入一个测试货运单,复核真实图片、规格、TWD 原始单价, + 再验证无采购关联删除和完整单号重新导入;不删除真实 ERP 数据,不把真实数据或诊断 + 响应提交到仓库。 ## 当前可运行内容 diff --git a/docs/tasks/T-242.md b/docs/tasks/T-242.md index f73008a..f7ace7b 100644 --- a/docs/tasks/T-242.md +++ b/docs/tasks/T-242.md @@ -4,7 +4,7 @@ title: 增加受约束的本地货运单删除 phase: 2 deps: - T-241 -status: TODO +status: DONE created: 2026-07-29 context_ref: 7e3aaac work_branch: null @@ -41,7 +41,8 @@ Admin 可以导入、查看和生成采购需求,但误导入或不再需要 3. 无采购关联时先收集 READY 图片 `storage_key`,再删除全部商品明细和货运单;图片数据库 记录依赖现有 `ON DELETE CASCADE`。保留 `erp_sync_runs` 和增量水位作为同步审计。 4. 文件删除在数据库提交后以独立短超时执行。失败文件不再有数据库记录或鉴权读取路径, - 不把文件系统失败伪装成数据库回滚;记录不含真实订单内容的受控诊断事件。 + 不把文件系统失败伪装成数据库回滚,也不记录 storage key 或真实订单内容;Web 成功事件 + 只记录内部订单 UUID 和操作人 UUID。 5. Admin API 增加 `DELETE /api/v1/freight-orders/:id`:首次或重复删除均返回 `204`, 其他 ADMIN 作用域中的 ID 也按不存在处理;已有采购关联返回 `409` 和稳定错误码 `FREIGHT_ORDER_IN_USE`。 @@ -52,16 +53,16 @@ Admin 可以导入、查看和生成采购需求,但误导入或不再需要 ## 验收要点 -- [ ] 未创建采购需求的货运单可删除,订单、商品和图片数据库记录均不存在。 -- [ ] 所有缓存图片文件在提交后删除;文件已不存在时仍视为成功。 -- [ ] 任一商品有关联采购需求时整单返回冲突,订单、全部商品、图片和采购记录均不变化。 -- [ ] 删除不改变同步运行历史、同步水位或顺运宝 ERP 数据。 -- [ ] 重复 API 删除为幂等 `204`;无权访问的 ID 不泄漏存在性。 -- [ ] Web 删除必须通过 ADMIN session、CSRF 和确认交互,成功/冲突反馈明确。 -- [ ] 删除后使用相同完整订单号重新导入可创建新的本地货运快照。 -- [ ] 既有货运导入、图片读取、采购需求和采购任务流程不回归。 -- [ ] 1440×900、390×844 和 375×667 下详情操作区无重叠、溢出或误触风险。 -- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。 +- [x] 未创建采购需求的货运单可删除,订单、商品和图片数据库记录均不存在。 +- [x] 所有缓存图片文件在提交后删除;文件已不存在时仍视为成功。 +- [x] 任一商品有关联采购需求时整单返回冲突,订单、全部商品、图片和采购记录均不变化。 +- [x] 删除不改变同步运行历史、同步水位或顺运宝 ERP 数据。 +- [x] 重复 API 删除为幂等 `204`;无权访问的 ID 不泄漏存在性。 +- [x] Web 删除必须通过 ADMIN session、CSRF 和确认交互,成功/冲突反馈明确。 +- [x] 删除后使用相同完整订单号重新导入可创建新的本地货运快照。 +- [x] 既有货运导入、图片读取、采购需求和采购任务流程不回归。 +- [x] 1440×900、390×844 和 375×667 下详情操作区无重叠、溢出或误触风险。 +- [x] 标准 Go 测试、race、vet 和三个入口构建通过。 ## 边界 @@ -75,3 +76,14 @@ Admin 可以导入、查看和生成采购需求,但误导入或不再需要 - 2026-07-29:创建任务。第一版采用详情页单单删除和服务端强约束,以最小界面面积提供 可恢复的本地清理能力;采购审计优先于删除便利性。 +- 2026-07-29:仓储使用现有 `_txlock=immediate` 事务按 ADMIN 作用域检查整单采购引用; + 成功时收集 READY 图片键、删除全部商品和订单并保留同步运行/水位。跨作用域与重复删除 + 均为空操作,采购关联映射为 `FREIGHT_ORDER_IN_USE`;同单号新同步可建立全新 revision 1 + 快照。本任务不需要 schema migration。 +- 2026-07-29:图片服务在数据库提交后用 5 秒总预算调用受路径约束且幂等的本地删除; + Admin API `DELETE` 返回 204/409,SSR POST 强制 CSRF 与 `confirm_delete=1`,详情页使用 + 二次确认 dialog 和 noscript 复选框回退,成功事件只记录内部 UUID。 +- 2026-07-29:`go test ./...`、`go test -race ./...`、`go vet ./...` 和 API、authctl、 + migrate 三个入口构建通过。Playwright 连接隔离 Gin/SQLite,在 1440×900、390×844、 + 375×667 验证弹窗无越界,首次焦点位于保留动作,三个操作按钮均高 44px;实际删除后 + 跳转空列表、原图片接口返回 404、本地 JPEG 消失,控制台无警告或错误。