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
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user