feat(t242): delete unused freight orders
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -617,6 +617,112 @@ func (store *Store) GetFreightOrder(
|
|||||||
return domain.FreightOrderDetail{Order: order, Items: items}, nil
|
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
|
const freightSyncSelect = `SELECT
|
||||||
id, creator_subject, created_by_user_id, mode, order_number,
|
id, creator_subject, created_by_user_id, mode, order_number,
|
||||||
created_from, created_to, watermark_through, query_sha256, status,
|
created_from, created_to, watermark_through, query_sha256, status,
|
||||||
|
|||||||
@@ -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", handler.listFreightOrders)
|
||||||
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
|
||||||
|
routes.DELETE("/api/v1/freight-orders/:id", handler.deleteFreightOrder)
|
||||||
}
|
}
|
||||||
if services.FreightImages != nil {
|
if services.FreightImages != nil {
|
||||||
routes.GET(
|
routes.GET(
|
||||||
|
|||||||
@@ -534,6 +534,92 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
|||||||
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
|
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
|
||||||
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
|
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) {
|
func TestAdminFreightDateSyncAdvancesInspectableWatermark(t *testing.T) {
|
||||||
@@ -687,6 +773,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
|||||||
createRequest.Body,
|
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 {
|
var requestBody struct {
|
||||||
Request struct {
|
Request struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -255,6 +255,23 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusOK, response)
|
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 {
|
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"id": run.ID,
|
"id": run.ID,
|
||||||
|
|||||||
@@ -89,6 +89,11 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
|||||||
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
|
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
|
||||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
||||||
|
routes.POST(
|
||||||
|
"/freight/:id/delete",
|
||||||
|
SecurityHeaders(),
|
||||||
|
h.DeleteFreightOrder,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if _, ok := h.service.(ProcurementService); ok {
|
if _, ok := h.service.(ProcurementService); ok {
|
||||||
routes.POST(
|
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) {
|
func (h *Handler) CreateFreightProcurementRequest(ctx *gin.Context) {
|
||||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
|
||||||
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
|
||||||
@@ -662,6 +718,10 @@ func freightNotice(value string) string {
|
|||||||
return "需求状态或来源已变化,当前不能生成任务。"
|
return "需求状态或来源已变化,当前不能生成任务。"
|
||||||
case "import-succeeded":
|
case "import-succeeded":
|
||||||
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
||||||
|
case "deleted":
|
||||||
|
return "货运单已从本系统删除,不影响顺运宝 ERP。"
|
||||||
|
case "delete-conflict":
|
||||||
|
return "该货运单已进入采购流程,不能删除。"
|
||||||
default:
|
default:
|
||||||
return ""
|
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) {
|
func TestFreightPresentationFormatsMoneyAndImageStates(t *testing.T) {
|
||||||
value := int64(12905)
|
value := int64(12905)
|
||||||
if got := formatMinorCurrency(&value, "TWD"); got != "TWD 129.05" {
|
if got := formatMinorCurrency(&value, "TWD"); got != "TWD 129.05" {
|
||||||
@@ -1348,6 +1444,8 @@ type fakeFreightService struct {
|
|||||||
*fakeService
|
*fakeService
|
||||||
orders []FreightOrder
|
orders []FreightOrder
|
||||||
orderDetail FreightOrderDetail
|
orderDetail FreightOrderDetail
|
||||||
|
deleteInput DeleteFreightOrderInput
|
||||||
|
deleteErr error
|
||||||
sync FreightSync
|
sync FreightSync
|
||||||
createInput CreateFreightSyncInput
|
createInput CreateFreightSyncInput
|
||||||
createResult FreightSync
|
createResult FreightSync
|
||||||
@@ -1448,6 +1546,14 @@ func (service *fakeFreightService) GetFreightOrder(
|
|||||||
return service.orderDetail, service.err
|
return service.orderDetail, service.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *fakeFreightService) DeleteFreightOrder(
|
||||||
|
_ context.Context,
|
||||||
|
input DeleteFreightOrderInput,
|
||||||
|
) error {
|
||||||
|
service.deleteInput = input
|
||||||
|
return service.deleteErr
|
||||||
|
}
|
||||||
|
|
||||||
func (service *fakeFreightService) GetFreightSync(
|
func (service *fakeFreightService) GetFreightSync(
|
||||||
context.Context,
|
context.Context,
|
||||||
string,
|
string,
|
||||||
|
|||||||
@@ -119,4 +119,23 @@
|
|||||||
openCancel.focus();
|
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>
|
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
|
||||||
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
|
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
|
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
|
||||||
<section class="detail-section" aria-labelledby="freight-source-title">
|
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||||
@@ -129,6 +133,38 @@
|
|||||||
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
|
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ var (
|
|||||||
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
||||||
ErrFreightSyncBusy = errors.New("freight sync is busy")
|
ErrFreightSyncBusy = errors.New("freight sync is busy")
|
||||||
ErrFreightSyncTimeout = errors.New("freight sync timed out")
|
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.
|
// Service is the application boundary required by the server-rendered admin UI.
|
||||||
@@ -40,6 +41,7 @@ type Service interface {
|
|||||||
type FreightService interface {
|
type FreightService interface {
|
||||||
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
||||||
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
||||||
|
DeleteFreightOrder(context.Context, DeleteFreightOrderInput) error
|
||||||
GetFreightSync(context.Context, string) (FreightSync, error)
|
GetFreightSync(context.Context, string) (FreightSync, error)
|
||||||
GetFreightWatermark(context.Context) (*FreightWatermark, error)
|
GetFreightWatermark(context.Context) (*FreightWatermark, error)
|
||||||
CreateFreightSync(
|
CreateFreightSync(
|
||||||
@@ -135,6 +137,11 @@ type CreateFreightSyncInput struct {
|
|||||||
SyncToNow bool
|
SyncToNow bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeleteFreightOrderInput struct {
|
||||||
|
ActorUserID string
|
||||||
|
OrderID string
|
||||||
|
}
|
||||||
|
|
||||||
type FreightOrder struct {
|
type FreightOrder struct {
|
||||||
ID string
|
ID string
|
||||||
ExternalStockID string
|
ExternalStockID string
|
||||||
|
|||||||
@@ -192,6 +192,27 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
|
|||||||
}, nil
|
}, 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(
|
func (adapter *UsecaseAdapter) CreateProcurementRequest(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input CreateProcurementRequestInput,
|
input CreateProcurementRequestInput,
|
||||||
@@ -933,6 +954,10 @@ func mapUsecaseError(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var public error
|
var public error
|
||||||
|
if typed.Code == "FREIGHT_ORDER_IN_USE" {
|
||||||
|
public = ErrFreightOrderInUse
|
||||||
|
return &adapterError{public: public, cause: err}
|
||||||
|
}
|
||||||
switch typed.Kind {
|
switch typed.Kind {
|
||||||
case usecase.ErrorKindInvalid:
|
case usecase.ErrorKindInvalid:
|
||||||
if typed.Code == "TASK_CANCEL_INVALID" {
|
if typed.Code == "TASK_CANCEL_INVALID" {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ var (
|
|||||||
ErrSourceReferenceConflict = errors.New("source reference conflict")
|
ErrSourceReferenceConflict = errors.New("source reference conflict")
|
||||||
ErrAssetUnavailable = errors.New("asset is unavailable")
|
ErrAssetUnavailable = errors.New("asset is unavailable")
|
||||||
ErrTaskStateConflict = errors.New("task state conflict")
|
ErrTaskStateConflict = errors.New("task state conflict")
|
||||||
|
ErrFreightOrderInUse = errors.New("freight order is in use")
|
||||||
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
||||||
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
||||||
)
|
)
|
||||||
@@ -106,6 +107,13 @@ func wrapRepositoryError(err error) error {
|
|||||||
"task state does not allow this operation",
|
"task state does not allow this operation",
|
||||||
err,
|
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):
|
case errors.Is(err, ErrRepositoryUnavailable):
|
||||||
result := newError(
|
result := newError(
|
||||||
ErrorKindUnavailable,
|
ErrorKindUnavailable,
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ type FreightImageRepository interface {
|
|||||||
|
|
||||||
type FreightImageCache interface {
|
type FreightImageCache interface {
|
||||||
CacheRun(context.Context, string, string) error
|
CacheRun(context.Context, string, string) error
|
||||||
|
DeleteStoredImages([]string)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,20 @@ func (service *FreightImageService) deleteStoredImage(storageKey string) {
|
|||||||
_ = service.store.Delete(ctx, storageKey)
|
_ = 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(
|
func (service *FreightImageService) OpenItemImage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
creatorSubject, itemID string,
|
creatorSubject, itemID string,
|
||||||
|
|||||||
@@ -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 {
|
type freightImageRepositoryFake struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
jobs []domain.FreightItemImageJob
|
jobs []domain.FreightItemImageJob
|
||||||
|
|||||||
@@ -58,4 +58,9 @@ type FreightRepository interface {
|
|||||||
string,
|
string,
|
||||||
string,
|
string,
|
||||||
) (domain.FreightOrderDetail, error)
|
) (domain.FreightOrderDetail, error)
|
||||||
|
DeleteFreightOrder(
|
||||||
|
context.Context,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ type CreateFreightDateSyncCommand struct {
|
|||||||
SyncToNow bool
|
SyncToNow bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeleteFreightOrderCommand struct {
|
||||||
|
CreatorSubject string
|
||||||
|
ActorUserID string
|
||||||
|
OrderID string
|
||||||
|
}
|
||||||
|
|
||||||
type CreateFreightSyncResult struct {
|
type CreateFreightSyncResult struct {
|
||||||
Run domain.FreightSyncRun
|
Run domain.FreightSyncRun
|
||||||
Replayed bool
|
Replayed bool
|
||||||
@@ -701,6 +707,44 @@ func (service *FreightService) GetOrder(
|
|||||||
return detail, nil
|
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(
|
func (service *FreightService) normalize(
|
||||||
source domain.FreightSourceBatch,
|
source domain.FreightSourceBatch,
|
||||||
) (domain.FreightImportBatch, error) {
|
) (domain.FreightImportBatch, error) {
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ type recordingFreightImageCache struct {
|
|||||||
creatorSubject string
|
creatorSubject string
|
||||||
runID string
|
runID string
|
||||||
err error
|
err error
|
||||||
|
deleted []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cache *recordingFreightImageCache) CacheRun(
|
func (cache *recordingFreightImageCache) CacheRun(
|
||||||
@@ -248,6 +249,12 @@ func (cache *recordingFreightImageCache) CacheRun(
|
|||||||
return cache.err
|
return cache.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cache *recordingFreightImageCache) DeleteStoredImages(
|
||||||
|
storageKeys []string,
|
||||||
|
) {
|
||||||
|
cache.deleted = append(cache.deleted, storageKeys...)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
|
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
|
||||||
repository := &syncTrackingRepository{}
|
repository := &syncTrackingRepository{}
|
||||||
source := &blockingOrderSource{}
|
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) {
|
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
|
||||||
source := &recordingDateSource{}
|
source := &recordingDateSource{}
|
||||||
service := &FreightService{source: source}
|
service := &FreightService{source: source}
|
||||||
@@ -581,12 +638,17 @@ func (*blockingOrderSource) QueryCreatedRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type syncTrackingRepository struct {
|
type syncTrackingRepository struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
startCalls int
|
startCalls int
|
||||||
completeCalls int
|
completeCalls int
|
||||||
failCalls int
|
failCalls int
|
||||||
failCode string
|
failCode string
|
||||||
failContextErr error
|
failContextErr error
|
||||||
|
deleteCalls int
|
||||||
|
deleteCreatorSubject string
|
||||||
|
deleteOrderID string
|
||||||
|
deleteStorageKeys []string
|
||||||
|
deleteErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*syncTrackingRepository) CreateFreightSync(
|
func (*syncTrackingRepository) CreateFreightSync(
|
||||||
@@ -682,6 +744,17 @@ func (*syncTrackingRepository) GetFreightOrder(
|
|||||||
return domain.FreightOrderDetail{}, nil
|
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 {
|
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
|
||||||
source.ensureCalls++
|
source.ensureCalls++
|
||||||
return source.ensureErr
|
return source.ensureErr
|
||||||
@@ -800,3 +873,11 @@ func (*dateCaptureRepository) GetFreightOrder(
|
|||||||
) (domain.FreightOrderDetail, error) {
|
) (domain.FreightOrderDetail, error) {
|
||||||
return domain.FreightOrderDetail{}, nil
|
return domain.FreightOrderDetail{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (*dateCaptureRepository) DeleteFreightOrder(
|
||||||
|
context.Context,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
) ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|||||||
+15
-8
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-29
|
- 日期:2026-07-29
|
||||||
- 阶段:T-241 已完成 ERP 货运列表与商品详情展示优化
|
- 阶段:T-242 已完成受约束的本地货运单删除
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -21,7 +21,9 @@
|
|||||||
`FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情
|
`FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情
|
||||||
`created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存
|
`created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存
|
||||||
`productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权
|
`productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权
|
||||||
本地路由、货运列表和详情中显示图片、状态与采购字段。
|
本地路由、货运列表和详情中显示图片、状态与采购字段。T-242 允许 ADMIN 从详情页删除
|
||||||
|
尚未进入采购流程的本地货运快照及缓存图;已有采购引用时原子拒绝,不修改 ERP、同步
|
||||||
|
运行历史或增量水位,完整订单号可重新导入。
|
||||||
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
|
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
|
||||||
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226
|
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226
|
||||||
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||||
Debug APK `1.4.16 (21)` 已覆盖安装到 PKG110
|
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 会话预检、完整单号、日期分页去重、
|
和三个 Go 入口构建;T-227 增加 Go source 的伪 ERP 会话预检、完整单号、日期分页去重、
|
||||||
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
||||||
验证也通过,均未访问真实 ERP;
|
验证也通过,均未访问真实 ERP;
|
||||||
@@ -59,6 +61,9 @@
|
|||||||
总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、
|
总项数;详情展示 84px/移动端 72px 本地图片、单一规格 SKU、数量、TWD 原始单价、
|
||||||
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
|
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
|
||||||
破图、遮挡和控制台错误。
|
破图、遮挡和控制台错误。
|
||||||
|
- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE;
|
||||||
|
删除事务覆盖全部历史商品并阻止任何采购引用,提交后以有界预算清理缓存 JPEG。真实
|
||||||
|
隔离服务已验证成功后列表为空、图片 404 和文件消失;顺运宝 ERP 未被调用。
|
||||||
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
||||||
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
||||||
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
||||||
@@ -210,6 +215,7 @@
|
|||||||
| `docs/tasks/T-239.md` | DONE | 冻结货运规格 SKU、TWD 原始单价和图片引用契约 |
|
| `docs/tasks/T-239.md` | DONE | 冻结货运规格 SKU、TWD 原始单价和图片引用契约 |
|
||||||
| `docs/tasks/T-240.md` | DONE | 后端受控缓存并鉴权提供货运商品图片 |
|
| `docs/tasks/T-240.md` | DONE | 后端受控缓存并鉴权提供货运商品图片 |
|
||||||
| `docs/tasks/T-241.md` | DONE | 优化货运列表代表图与商品采购信息展示 |
|
| `docs/tasks/T-241.md` | DONE | 优化货运列表代表图与商品采购信息展示 |
|
||||||
|
| `docs/tasks/T-242.md` | DONE | 受约束删除本地货运快照并保护采购审计 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
@@ -221,13 +227,14 @@
|
|||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
- 已完成: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
|
直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector
|
||||||
清理、受控本地凭证加载、同步单号导入、分钟时间兼容、货运商品元数据契约、本地图片缓存
|
清理、受控本地凭证加载、同步单号导入、分钟时间兼容、货运商品元数据契约、本地图片缓存、
|
||||||
和货运管理展示优化。
|
货运管理展示优化和受约束本地删除。
|
||||||
- 进行中:无。
|
- 进行中:无。
|
||||||
- 下一步:用真实 ERP 账号受控导入一个测试货运单,复核真实图片响应、缓存状态、规格、
|
- 下一步:用真实 ERP 账号受控导入一个测试货运单,复核真实图片、规格、TWD 原始单价,
|
||||||
TWD 原始单价和重新导入幂等性;不把真实数据或诊断响应提交到仓库。
|
再验证无采购关联删除和完整单号重新导入;不删除真实 ERP 数据,不把真实数据或诊断
|
||||||
|
响应提交到仓库。
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
+24
-12
@@ -4,7 +4,7 @@ title: 增加受约束的本地货运单删除
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-241
|
- T-241
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-29
|
created: 2026-07-29
|
||||||
context_ref: 7e3aaac
|
context_ref: 7e3aaac
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -41,7 +41,8 @@ Admin 可以导入、查看和生成采购需求,但误导入或不再需要
|
|||||||
3. 无采购关联时先收集 READY 图片 `storage_key`,再删除全部商品明细和货运单;图片数据库
|
3. 无采购关联时先收集 READY 图片 `storage_key`,再删除全部商品明细和货运单;图片数据库
|
||||||
记录依赖现有 `ON DELETE CASCADE`。保留 `erp_sync_runs` 和增量水位作为同步审计。
|
记录依赖现有 `ON DELETE CASCADE`。保留 `erp_sync_runs` 和增量水位作为同步审计。
|
||||||
4. 文件删除在数据库提交后以独立短超时执行。失败文件不再有数据库记录或鉴权读取路径,
|
4. 文件删除在数据库提交后以独立短超时执行。失败文件不再有数据库记录或鉴权读取路径,
|
||||||
不把文件系统失败伪装成数据库回滚;记录不含真实订单内容的受控诊断事件。
|
不把文件系统失败伪装成数据库回滚,也不记录 storage key 或真实订单内容;Web 成功事件
|
||||||
|
只记录内部订单 UUID 和操作人 UUID。
|
||||||
5. Admin API 增加 `DELETE /api/v1/freight-orders/:id`:首次或重复删除均返回 `204`,
|
5. Admin API 增加 `DELETE /api/v1/freight-orders/:id`:首次或重复删除均返回 `204`,
|
||||||
其他 ADMIN 作用域中的 ID 也按不存在处理;已有采购关联返回 `409` 和稳定错误码
|
其他 ADMIN 作用域中的 ID 也按不存在处理;已有采购关联返回 `409` 和稳定错误码
|
||||||
`FREIGHT_ORDER_IN_USE`。
|
`FREIGHT_ORDER_IN_USE`。
|
||||||
@@ -52,16 +53,16 @@ Admin 可以导入、查看和生成采购需求,但误导入或不再需要
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] 未创建采购需求的货运单可删除,订单、商品和图片数据库记录均不存在。
|
- [x] 未创建采购需求的货运单可删除,订单、商品和图片数据库记录均不存在。
|
||||||
- [ ] 所有缓存图片文件在提交后删除;文件已不存在时仍视为成功。
|
- [x] 所有缓存图片文件在提交后删除;文件已不存在时仍视为成功。
|
||||||
- [ ] 任一商品有关联采购需求时整单返回冲突,订单、全部商品、图片和采购记录均不变化。
|
- [x] 任一商品有关联采购需求时整单返回冲突,订单、全部商品、图片和采购记录均不变化。
|
||||||
- [ ] 删除不改变同步运行历史、同步水位或顺运宝 ERP 数据。
|
- [x] 删除不改变同步运行历史、同步水位或顺运宝 ERP 数据。
|
||||||
- [ ] 重复 API 删除为幂等 `204`;无权访问的 ID 不泄漏存在性。
|
- [x] 重复 API 删除为幂等 `204`;无权访问的 ID 不泄漏存在性。
|
||||||
- [ ] Web 删除必须通过 ADMIN session、CSRF 和确认交互,成功/冲突反馈明确。
|
- [x] Web 删除必须通过 ADMIN session、CSRF 和确认交互,成功/冲突反馈明确。
|
||||||
- [ ] 删除后使用相同完整订单号重新导入可创建新的本地货运快照。
|
- [x] 删除后使用相同完整订单号重新导入可创建新的本地货运快照。
|
||||||
- [ ] 既有货运导入、图片读取、采购需求和采购任务流程不回归。
|
- [x] 既有货运导入、图片读取、采购需求和采购任务流程不回归。
|
||||||
- [ ] 1440×900、390×844 和 375×667 下详情操作区无重叠、溢出或误触风险。
|
- [x] 1440×900、390×844 和 375×667 下详情操作区无重叠、溢出或误触风险。
|
||||||
- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。
|
- [x] 标准 Go 测试、race、vet 和三个入口构建通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -75,3 +76,14 @@ Admin 可以导入、查看和生成采购需求,但误导入或不再需要
|
|||||||
|
|
||||||
- 2026-07-29:创建任务。第一版采用详情页单单删除和服务端强约束,以最小界面面积提供
|
- 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 消失,控制台无警告或错误。
|
||||||
|
|||||||
Reference in New Issue
Block a user