fix(t243): allow deleting unreferenced procurement drafts
This commit is contained in:
@@ -140,22 +140,125 @@ func TestDeleteFreightOrderIsScopedIdempotentAndReimportable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
func TestDeleteFreightOrderRemovesUnprotectedProcurementDrafts(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
for index, status := range []domain.ProcurementRequestStatus{
|
||||||
|
domain.ProcurementBlocked,
|
||||||
|
domain.ProcurementNeedsImage,
|
||||||
|
domain.ProcurementSourceChanged,
|
||||||
|
} {
|
||||||
|
t.Run(string(status), func(t *testing.T) {
|
||||||
|
db := openDatabase(t)
|
||||||
|
store, _ := repository.New(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
|
||||||
|
userID := uuid(2430 + index*10)
|
||||||
|
seedFreightUser(t, db, userID, now)
|
||||||
|
|
||||||
|
run := freightRun(2431+index*10, userID, now)
|
||||||
|
createAndStartFreightRun(
|
||||||
|
t,
|
||||||
|
store,
|
||||||
|
run,
|
||||||
|
"delete-draft-"+string(status),
|
||||||
|
"4",
|
||||||
|
)
|
||||||
|
if err := store.CompleteFreightSync(
|
||||||
|
ctx,
|
||||||
|
run,
|
||||||
|
freightBatch(2432+index*10, "a", "b"),
|
||||||
|
now.Add(time.Second),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||||
|
}
|
||||||
|
orders, _ := store.ListFreightOrders(
|
||||||
|
ctx,
|
||||||
|
"local-admin",
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
detail, _ := store.GetFreightOrder(
|
||||||
|
ctx,
|
||||||
|
"local-admin",
|
||||||
|
orders[0].ID,
|
||||||
|
)
|
||||||
|
procurement, err := usecase.NewProcurementService(
|
||||||
|
store,
|
||||||
|
procurementClock{now: now.Add(2 * time.Second)},
|
||||||
|
&procurementIDs{next: 2440 + index*10},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewProcurementService() error = %v", err)
|
||||||
|
}
|
||||||
|
result, err := procurement.CreateRequest(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementRequestCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
FreightOrderItemID: detail.Items[0].ID,
|
||||||
|
ConfirmProcurementNeeded: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
if status != domain.ProcurementNeedsImage {
|
||||||
|
blockingCode := "TITLE_REQUIRED"
|
||||||
|
if status == domain.ProcurementSourceChanged {
|
||||||
|
blockingCode = "SOURCE_CHANGED"
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`UPDATE procurement_requests
|
||||||
|
SET status = ?, blocking_code = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
status,
|
||||||
|
blockingCode,
|
||||||
|
result.Request.ID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("set draft status %s: %v", status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := store.DeleteFreightOrder(
|
||||||
|
ctx,
|
||||||
|
"local-admin",
|
||||||
|
orders[0].ID,
|
||||||
|
)
|
||||||
|
if err != nil || len(keys) != 0 {
|
||||||
|
t.Fatalf("draft delete = %#v, %v", keys, err)
|
||||||
|
}
|
||||||
|
for table, want := range map[string]int{
|
||||||
|
"freight_orders": 0,
|
||||||
|
"freight_order_items": 0,
|
||||||
|
"procurement_requests": 0,
|
||||||
|
} {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(
|
||||||
|
"SELECT count(*) FROM " + table,
|
||||||
|
).Scan(&count); err != nil || count != want {
|
||||||
|
t.Fatalf("%s count = %d, %v", table, count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteFreightOrderProtectsReferenceAndTaskAtomically(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
db := openDatabase(t)
|
db := openDatabase(t)
|
||||||
store, _ := repository.New(db)
|
store, _ := repository.New(db)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||||
userID := uuid(2430)
|
userID := uuid(2470)
|
||||||
seedFreightUser(t, db, userID, now)
|
seedFreightUser(t, db, userID, now)
|
||||||
|
|
||||||
run := freightRun(2431, userID, now)
|
run := freightRun(2471, userID, now)
|
||||||
createAndStartFreightRun(t, store, run, "delete-conflict", "4")
|
createAndStartFreightRun(t, store, run, "delete-protected", "5")
|
||||||
if err := store.CompleteFreightSync(
|
if err := store.CompleteFreightSync(
|
||||||
ctx,
|
ctx,
|
||||||
run,
|
run,
|
||||||
freightBatch(2432, "a", "b"),
|
freightBatch(2472, "a", "b"),
|
||||||
now.Add(time.Second),
|
now.Add(time.Second),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("CompleteFreightSync() error = %v", err)
|
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||||
@@ -169,12 +272,12 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
|||||||
procurement, err := usecase.NewProcurementService(
|
procurement, err := usecase.NewProcurementService(
|
||||||
store,
|
store,
|
||||||
procurementClock{now: now.Add(2 * time.Second)},
|
procurementClock{now: now.Add(2 * time.Second)},
|
||||||
&procurementIDs{next: 2440},
|
&procurementIDs{next: 2480},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewProcurementService() error = %v", err)
|
t.Fatalf("NewProcurementService() error = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := procurement.CreateRequest(
|
result, err := procurement.CreateRequest(
|
||||||
ctx,
|
ctx,
|
||||||
usecase.CreateProcurementRequestCommand{
|
usecase.CreateProcurementRequestCommand{
|
||||||
CreatorSubject: "local-admin",
|
CreatorSubject: "local-admin",
|
||||||
@@ -182,22 +285,75 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
|||||||
FreightOrderItemID: detail.Items[0].ID,
|
FreightOrderItemID: detail.Items[0].ID,
|
||||||
ConfirmProcurementNeeded: true,
|
ConfirmProcurementNeeded: true,
|
||||||
},
|
},
|
||||||
); err != nil {
|
)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("CreateRequest() error = %v", err)
|
t.Fatalf("CreateRequest() error = %v", err)
|
||||||
}
|
}
|
||||||
|
asset := domain.Asset{
|
||||||
|
ID: uuid(2490),
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
Purpose: domain.AssetPurposeTaskReference,
|
||||||
|
MediaType: domain.NormalizedImageMediaType,
|
||||||
|
SizeBytes: 100,
|
||||||
|
SHA256: repeatHex("f"),
|
||||||
|
StorageKey: "freight-delete/reference.jpg",
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
if _, _, err := store.CreateAssetIdempotent(
|
||||||
|
ctx,
|
||||||
|
asset,
|
||||||
|
"freight-delete-reference",
|
||||||
|
repeatHex("e"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("CreateAssetIdempotent() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := procurement.BindReference(
|
||||||
|
ctx,
|
||||||
|
usecase.BindProcurementReferenceCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
RequestID: result.Request.ID,
|
||||||
|
ImageAssetID: asset.ID,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("BindReference() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
keys, err := store.DeleteFreightOrder(
|
keys, err := store.DeleteFreightOrder(
|
||||||
ctx,
|
ctx,
|
||||||
"local-admin",
|
"local-admin",
|
||||||
orders[0].ID,
|
orders[0].ID,
|
||||||
)
|
)
|
||||||
if !errors.Is(err, usecase.ErrFreightOrderInUse) || len(keys) != 0 {
|
if !errors.Is(err, usecase.ErrFreightOrderHasReference) ||
|
||||||
t.Fatalf("conflicting delete = %#v, %v", keys, err)
|
len(keys) != 0 {
|
||||||
|
t.Fatalf("reference delete = %#v, %v", keys, err)
|
||||||
|
}
|
||||||
|
if _, err := procurement.CreateTask(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateProcurementTaskCommand{
|
||||||
|
CreatorSubject: "local-admin",
|
||||||
|
ActorUserID: userID,
|
||||||
|
RequestID: result.Request.ID,
|
||||||
|
IdempotencyKey: "freight-delete-task",
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("CreateTask() error = %v", err)
|
||||||
|
}
|
||||||
|
keys, err = store.DeleteFreightOrder(
|
||||||
|
ctx,
|
||||||
|
"local-admin",
|
||||||
|
orders[0].ID,
|
||||||
|
)
|
||||||
|
if !errors.Is(err, usecase.ErrFreightOrderHasPurchaseTask) ||
|
||||||
|
len(keys) != 0 {
|
||||||
|
t.Fatalf("task delete = %#v, %v", keys, err)
|
||||||
}
|
}
|
||||||
for table, want := range map[string]int{
|
for table, want := range map[string]int{
|
||||||
"freight_orders": 1,
|
"freight_orders": 1,
|
||||||
"freight_order_items": 2,
|
"freight_order_items": 2,
|
||||||
"procurement_requests": 1,
|
"procurement_requests": 1,
|
||||||
|
"purchase_tasks": 1,
|
||||||
|
"purchase_task_sources": 1,
|
||||||
} {
|
} {
|
||||||
var count int
|
var count int
|
||||||
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(
|
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(
|
||||||
|
|||||||
@@ -642,7 +642,63 @@ func (store *Store) DeleteFreightOrder(
|
|||||||
return nil, repositoryFailure(err)
|
return nil, repositoryFailure(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var inUse int
|
var hasPurchaseTask int
|
||||||
|
err = tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM freight_order_items AS item
|
||||||
|
JOIN procurement_requests AS request
|
||||||
|
ON request.freight_order_item_id = item.id
|
||||||
|
WHERE item.freight_order_id = ?
|
||||||
|
AND request.purchase_task_id IS NOT NULL
|
||||||
|
)`,
|
||||||
|
storedOrderID,
|
||||||
|
).Scan(&hasPurchaseTask)
|
||||||
|
if err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if hasPurchaseTask != 0 {
|
||||||
|
return nil, usecase.ErrFreightOrderHasPurchaseTask
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasReference int
|
||||||
|
err = tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM freight_order_items AS item
|
||||||
|
JOIN procurement_requests AS request
|
||||||
|
ON request.freight_order_item_id = item.id
|
||||||
|
WHERE item.freight_order_id = ?
|
||||||
|
AND request.reference_asset_id IS NOT NULL
|
||||||
|
)`,
|
||||||
|
storedOrderID,
|
||||||
|
).Scan(&hasReference)
|
||||||
|
if err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if hasReference != 0 {
|
||||||
|
return nil, usecase.ErrFreightOrderHasReference
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`DELETE FROM procurement_requests
|
||||||
|
WHERE freight_order_item_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM freight_order_items
|
||||||
|
WHERE freight_order_id = ?
|
||||||
|
)
|
||||||
|
AND reference_asset_id IS NULL
|
||||||
|
AND purchase_task_id IS NULL
|
||||||
|
AND status IN ('BLOCKED', 'NEEDS_IMAGE', 'SOURCE_CHANGED')`,
|
||||||
|
storedOrderID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingRequests int
|
||||||
err = tx.QueryRowContext(
|
err = tx.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT EXISTS (
|
`SELECT EXISTS (
|
||||||
@@ -653,12 +709,12 @@ func (store *Store) DeleteFreightOrder(
|
|||||||
WHERE item.freight_order_id = ?
|
WHERE item.freight_order_id = ?
|
||||||
)`,
|
)`,
|
||||||
storedOrderID,
|
storedOrderID,
|
||||||
).Scan(&inUse)
|
).Scan(&remainingRequests)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, repositoryFailure(err)
|
return nil, repositoryFailure(err)
|
||||||
}
|
}
|
||||||
if inUse != 0 {
|
if remainingRequests != 0 {
|
||||||
return nil, usecase.ErrFreightOrderInUse
|
return nil, repositoryFailure(usecase.ErrRepositoryInvariant)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := tx.QueryContext(
|
rows, err := tx.QueryContext(
|
||||||
|
|||||||
@@ -773,26 +773,6 @@ 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"`
|
||||||
@@ -829,6 +809,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
|||||||
!strings.Contains(bind.Body.String(), `"status":"READY"`) {
|
!strings.Contains(bind.Body.String(), `"status":"READY"`) {
|
||||||
t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body)
|
t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body)
|
||||||
}
|
}
|
||||||
|
deleteWithReference := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodDelete,
|
||||||
|
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if deleteWithReference.Code != http.StatusConflict ||
|
||||||
|
!strings.Contains(
|
||||||
|
deleteWithReference.Body.String(),
|
||||||
|
`"code":"FREIGHT_ORDER_HAS_REFERENCE"`,
|
||||||
|
) {
|
||||||
|
t.Fatalf(
|
||||||
|
"delete with reference status/body = %d / %s",
|
||||||
|
deleteWithReference.Code,
|
||||||
|
deleteWithReference.Body,
|
||||||
|
)
|
||||||
|
}
|
||||||
createTask := performAdminRequest(
|
createTask := performAdminRequest(
|
||||||
t,
|
t,
|
||||||
fixture.router,
|
fixture.router,
|
||||||
@@ -847,6 +847,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
|||||||
createTask.Body,
|
createTask.Body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
deleteWithTask := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodDelete,
|
||||||
|
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if deleteWithTask.Code != http.StatusConflict ||
|
||||||
|
!strings.Contains(
|
||||||
|
deleteWithTask.Body.String(),
|
||||||
|
`"code":"FREIGHT_ORDER_HAS_PURCHASE_TASK"`,
|
||||||
|
) {
|
||||||
|
t.Fatalf(
|
||||||
|
"delete with task status/body = %d / %s",
|
||||||
|
deleteWithTask.Code,
|
||||||
|
deleteWithTask.Body,
|
||||||
|
)
|
||||||
|
}
|
||||||
var taskBody struct {
|
var taskBody struct {
|
||||||
Task struct {
|
Task struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -899,6 +919,90 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminFreightDeleteRemovesNeedsImageDraft(t *testing.T) {
|
||||||
|
fixture := newAdminIntegrationFixture(t)
|
||||||
|
createSync := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/freight-syncs",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(
|
||||||
|
`{"mode":"ORDER_NUMBER","order_number":"DRAFT-DELETE"}`,
|
||||||
|
),
|
||||||
|
"draft-delete-freight-sync",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, createSync, http.StatusCreated)
|
||||||
|
orders := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/freight-orders",
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
var orderBody struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, orders, &orderBody)
|
||||||
|
detail := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
var detailBody struct {
|
||||||
|
Items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, detail, &detailBody)
|
||||||
|
createRequest := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/freight-items/"+detailBody.Items[0].ID+
|
||||||
|
"/procurement-request",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(`{"confirm_procurement_needed":true}`),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if createRequest.Code != http.StatusCreated ||
|
||||||
|
!strings.Contains(createRequest.Body.String(), `"status":"NEEDS_IMAGE"`) {
|
||||||
|
t.Fatalf(
|
||||||
|
"create request status/body = %d / %s",
|
||||||
|
createRequest.Code,
|
||||||
|
createRequest.Body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deleteDraft := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodDelete,
|
||||||
|
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, deleteDraft, http.StatusNoContent)
|
||||||
|
deletedDetail := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, deletedDetail, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||||
fixture := newAdminIntegrationFixture(t)
|
fixture := newAdminIntegrationFixture(t)
|
||||||
taskID, executionID, taskHash, firstKey, secondKey :=
|
taskID, executionID, taskHash, firstKey, secondKey :=
|
||||||
|
|||||||
@@ -561,10 +561,17 @@ func (h *Handler) DeleteFreightOrder(ctx *gin.Context) {
|
|||||||
OrderID: orderID,
|
OrderID: orderID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if errors.Is(err, ErrFreightOrderInUse) {
|
if errors.Is(err, ErrFreightOrderHasPurchaseTask) {
|
||||||
ctx.Redirect(
|
ctx.Redirect(
|
||||||
http.StatusSeeOther,
|
http.StatusSeeOther,
|
||||||
"/freight/"+pathEscape(orderID)+"?notice=delete-conflict",
|
"/freight/"+pathEscape(orderID)+"?notice=delete-task-conflict",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrFreightOrderHasReference) {
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/freight/"+pathEscape(orderID)+"?notice=delete-reference-conflict",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -720,8 +727,10 @@ func freightNotice(value string) string {
|
|||||||
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
||||||
case "deleted":
|
case "deleted":
|
||||||
return "货运单已从本系统删除,不影响顺运宝 ERP。"
|
return "货运单已从本系统删除,不影响顺运宝 ERP。"
|
||||||
case "delete-conflict":
|
case "delete-task-conflict":
|
||||||
return "该货运单已进入采购流程,不能删除。"
|
return "该货运单已生成采购任务,不能删除。"
|
||||||
|
case "delete-reference-conflict":
|
||||||
|
return "该货运单已绑定采购参考图,不能删除。"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1330,7 +1330,11 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
|
|||||||
)
|
)
|
||||||
if detail.Code != http.StatusOK ||
|
if detail.Code != http.StatusOK ||
|
||||||
!strings.Contains(detail.Body.String(), "删除本地货运单") ||
|
!strings.Contains(detail.Body.String(), "删除本地货运单") ||
|
||||||
!strings.Contains(detail.Body.String(), "data-freight-delete-dialog") {
|
!strings.Contains(detail.Body.String(), "data-freight-delete-dialog") ||
|
||||||
|
!strings.Contains(
|
||||||
|
detail.Body.String(),
|
||||||
|
"未绑定参考图的采购需求草稿",
|
||||||
|
) {
|
||||||
t.Fatalf("delete controls status/body = %d / %s", detail.Code, detail.Body)
|
t.Fatalf("delete controls status/body = %d / %s", detail.Code, detail.Body)
|
||||||
}
|
}
|
||||||
cookie := csrfCookie(t, detail)
|
cookie := csrfCookie(t, detail)
|
||||||
@@ -1374,7 +1378,7 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
service.deleteErr = ErrFreightOrderInUse
|
service.deleteErr = ErrFreightOrderHasReference
|
||||||
request = httptest.NewRequest(
|
request = httptest.NewRequest(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/freight/"+testTaskID+"/delete",
|
"/freight/"+testTaskID+"/delete",
|
||||||
@@ -1386,10 +1390,32 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
|
|||||||
router.ServeHTTP(response, request)
|
router.ServeHTTP(response, request)
|
||||||
if response.Code != http.StatusSeeOther ||
|
if response.Code != http.StatusSeeOther ||
|
||||||
response.Header().Get("Location") !=
|
response.Header().Get("Location") !=
|
||||||
"/freight/"+testTaskID+"?notice=delete-conflict" ||
|
"/freight/"+testTaskID+"?notice=delete-reference-conflict" ||
|
||||||
len(logged) != 1 {
|
len(logged) != 1 {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"conflicting delete status/location/log = %d / %q / %#v",
|
"reference conflict status/location/log = %d / %q / %#v",
|
||||||
|
response.Code,
|
||||||
|
response.Header().Get("Location"),
|
||||||
|
logged,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
service.deleteErr = ErrFreightOrderHasPurchaseTask
|
||||||
|
request = httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/freight/"+testTaskID+"/delete",
|
||||||
|
strings.NewReader(values.Encode()),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
response = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther ||
|
||||||
|
response.Header().Get("Location") !=
|
||||||
|
"/freight/"+testTaskID+"?notice=delete-task-conflict" ||
|
||||||
|
len(logged) != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"task conflict status/location/log = %d / %q / %#v",
|
||||||
response.Code,
|
response.Code,
|
||||||
response.Header().Get("Location"),
|
response.Header().Get("Location"),
|
||||||
logged,
|
logged,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
<noscript>
|
<noscript>
|
||||||
<section class="noscript-cancel" aria-labelledby="freight-delete-noscript-title">
|
<section class="noscript-cancel" aria-labelledby="freight-delete-noscript-title">
|
||||||
<h2 id="freight-delete-noscript-title">删除本地货运单</h2>
|
<h2 id="freight-delete-noscript-title">删除本地货运单</h2>
|
||||||
<p>只删除本系统保存的信息,不影响顺运宝 ERP;已进入采购流程的货运单不能删除。</p>
|
<p>只删除本系统保存的信息,不影响顺运宝 ERP;未绑定参考图的采购需求草稿会一并删除,已绑定参考图或已生成任务时系统拒绝删除。</p>
|
||||||
<form method="post" action="/freight/{{pathPart .Detail.Order.ID}}/delete">
|
<form method="post" action="/freight/{{pathPart .Detail.Order.ID}}/delete">
|
||||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||||
<label class="confirm-line">
|
<label class="confirm-line">
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
<dialog class="confirm-dialog" data-freight-delete-dialog
|
<dialog class="confirm-dialog" data-freight-delete-dialog
|
||||||
aria-labelledby="freight-delete-title">
|
aria-labelledby="freight-delete-title">
|
||||||
<h2 id="freight-delete-title">删除本地货运单?</h2>
|
<h2 id="freight-delete-title">删除本地货运单?</h2>
|
||||||
<p>将删除本系统保存的 {{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}该货运单{{end}} 及商品图片,不影响顺运宝 ERP。已进入采购流程时系统会拒绝删除。</p>
|
<p>将删除本系统保存的 {{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}该货运单{{end}}、商品图片及未绑定参考图的采购需求草稿,不影响顺运宝 ERP。已绑定参考图或已生成任务时系统拒绝删除。</p>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button class="button" type="button"
|
<button class="button" type="button"
|
||||||
data-keep-freight-order>保留货运单</button>
|
data-keep-freight-order>保留货运单</button>
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ 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")
|
ErrFreightOrderHasReference = errors.New(
|
||||||
|
"freight order has a procurement reference",
|
||||||
|
)
|
||||||
|
ErrFreightOrderHasPurchaseTask = errors.New(
|
||||||
|
"freight order has a purchase task",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service is the application boundary required by the server-rendered admin UI.
|
// Service is the application boundary required by the server-rendered admin UI.
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ func procurementStatusLabel(
|
|||||||
case domain.ProcurementBlocked:
|
case domain.ProcurementBlocked:
|
||||||
return "资料阻塞"
|
return "资料阻塞"
|
||||||
case domain.ProcurementNeedsImage:
|
case domain.ProcurementNeedsImage:
|
||||||
return "需要参考图"
|
return "需要参考图,尚未生成任务"
|
||||||
case domain.ProcurementReady:
|
case domain.ProcurementReady:
|
||||||
return "可以生成任务"
|
return "可以生成任务"
|
||||||
case domain.ProcurementTaskCreated:
|
case domain.ProcurementTaskCreated:
|
||||||
@@ -954,8 +954,12 @@ func mapUsecaseError(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var public error
|
var public error
|
||||||
if typed.Code == "FREIGHT_ORDER_IN_USE" {
|
if typed.Code == "FREIGHT_ORDER_HAS_PURCHASE_TASK" {
|
||||||
public = ErrFreightOrderInUse
|
public = ErrFreightOrderHasPurchaseTask
|
||||||
|
return &adapterError{public: public, cause: err}
|
||||||
|
}
|
||||||
|
if typed.Code == "FREIGHT_ORDER_HAS_REFERENCE" {
|
||||||
|
public = ErrFreightOrderHasReference
|
||||||
return &adapterError{public: public, cause: err}
|
return &adapterError{public: public, cause: err}
|
||||||
}
|
}
|
||||||
switch typed.Kind {
|
switch typed.Kind {
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ 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")
|
ErrFreightOrderHasReference = errors.New(
|
||||||
|
"freight order has a procurement reference",
|
||||||
|
)
|
||||||
|
ErrFreightOrderHasPurchaseTask = errors.New(
|
||||||
|
"freight order has a purchase task",
|
||||||
|
)
|
||||||
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
||||||
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
||||||
)
|
)
|
||||||
@@ -107,11 +112,18 @@ 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):
|
case errors.Is(err, ErrFreightOrderHasPurchaseTask):
|
||||||
return newError(
|
return newError(
|
||||||
ErrorKindConflict,
|
ErrorKindConflict,
|
||||||
"FREIGHT_ORDER_IN_USE",
|
"FREIGHT_ORDER_HAS_PURCHASE_TASK",
|
||||||
"freight order is already used by procurement",
|
"freight order already has a purchase task",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
case errors.Is(err, ErrFreightOrderHasReference):
|
||||||
|
return newError(
|
||||||
|
ErrorKindConflict,
|
||||||
|
"FREIGHT_ORDER_HAS_REFERENCE",
|
||||||
|
"freight order already has a procurement reference",
|
||||||
err,
|
err,
|
||||||
)
|
)
|
||||||
case errors.Is(err, ErrRepositoryUnavailable):
|
case errors.Is(err, ErrRepositoryUnavailable):
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncComman
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteFreightOrderValidatesConflictAndCleansImages(t *testing.T) {
|
func TestDeleteFreightOrderValidatesConflictsAndCleansImages(t *testing.T) {
|
||||||
repository := &syncTrackingRepository{
|
repository := &syncTrackingRepository{
|
||||||
deleteStorageKeys: []string{"first/image.jpg", "second/image.jpg"},
|
deleteStorageKeys: []string{"first/image.jpg", "second/image.jpg"},
|
||||||
}
|
}
|
||||||
@@ -420,17 +420,34 @@ func TestDeleteFreightOrderValidatesConflictAndCleansImages(t *testing.T) {
|
|||||||
t.Fatalf("deleted image keys = %#v", imageCache.deleted)
|
t.Fatalf("deleted image keys = %#v", imageCache.deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.deleteErr = ErrFreightOrderInUse
|
repository.deleteErr = ErrFreightOrderHasReference
|
||||||
err = service.DeleteOrder(context.Background(), command)
|
err = service.DeleteOrder(context.Background(), command)
|
||||||
assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_ORDER_IN_USE")
|
assertUsecaseError(
|
||||||
|
t,
|
||||||
|
err,
|
||||||
|
ErrorKindConflict,
|
||||||
|
"FREIGHT_ORDER_HAS_REFERENCE",
|
||||||
|
)
|
||||||
if len(imageCache.deleted) != 2 {
|
if len(imageCache.deleted) != 2 {
|
||||||
t.Fatalf("conflicting delete cleaned images = %#v", imageCache.deleted)
|
t.Fatalf("reference conflict cleaned images = %#v", imageCache.deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.deleteErr = ErrFreightOrderHasPurchaseTask
|
||||||
|
err = service.DeleteOrder(context.Background(), command)
|
||||||
|
assertUsecaseError(
|
||||||
|
t,
|
||||||
|
err,
|
||||||
|
ErrorKindConflict,
|
||||||
|
"FREIGHT_ORDER_HAS_PURCHASE_TASK",
|
||||||
|
)
|
||||||
|
if len(imageCache.deleted) != 2 {
|
||||||
|
t.Fatalf("task conflict cleaned images = %#v", imageCache.deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
command.OrderID = "invalid"
|
command.OrderID = "invalid"
|
||||||
err = service.DeleteOrder(context.Background(), command)
|
err = service.DeleteOrder(context.Background(), command)
|
||||||
assertUsecaseError(t, err, ErrorKindInvalid, "FREIGHT_DELETE_INVALID")
|
assertUsecaseError(t, err, ErrorKindInvalid, "FREIGHT_DELETE_INVALID")
|
||||||
if repository.deleteCalls != 2 {
|
if repository.deleteCalls != 3 {
|
||||||
t.Fatalf("invalid delete reached repository %d times", repository.deleteCalls)
|
t.Fatalf("invalid delete reached repository %d times", repository.deleteCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-29
|
- 日期:2026-07-29
|
||||||
- 阶段:T-242 已完成受约束的本地货运单删除
|
- 阶段:T-243 已完成货运删除的采购需求草稿边界修复
|
||||||
- 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,9 +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 从详情页删除
|
本地路由、货运列表和详情中显示图片、状态与采购字段。T-242/T-243 允许 ADMIN 从
|
||||||
尚未进入采购流程的本地货运快照及缓存图;已有采购引用时原子拒绝,不修改 ERP、同步
|
详情页删除本地货运快照、缓存图以及无参考图/任务的采购需求草稿;已绑定参考图或已生成
|
||||||
运行历史或增量水位,完整订单号可重新导入。
|
采购任务时分别原子拒绝,不修改 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 的
|
||||||
@@ -45,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-242 已运行 `go test ./...`、`go test -race ./...`、`go vet ./...`
|
- 后端测试:T-226 至 T-243 已运行 `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;
|
||||||
@@ -62,8 +62,9 @@
|
|||||||
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
|
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
|
||||||
破图、遮挡和控制台错误。
|
破图、遮挡和控制台错误。
|
||||||
- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE;
|
- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE;
|
||||||
删除事务覆盖全部历史商品并阻止任何采购引用,提交后以有界预算清理缓存 JPEG。真实
|
删除事务覆盖全部历史商品并清理无引用的 `BLOCKED`、`NEEDS_IMAGE`、`SOURCE_CHANGED`
|
||||||
隔离服务已验证成功后列表为空、图片 404 和文件消失;顺运宝 ERP 未被调用。
|
采购草稿;已绑定参考图或采购任务分别返回稳定冲突,任务优先。提交后以有界预算清理
|
||||||
|
缓存 JPEG。真实隔离服务已验证草稿随货运单删除后列表为空;顺运宝 ERP 未被调用。
|
||||||
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
||||||
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
||||||
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
||||||
|
|||||||
+23
-11
@@ -4,7 +4,7 @@ title: 允许货运删除清理未完成采购需求草稿
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-242
|
- T-242
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-29
|
created: 2026-07-29
|
||||||
context_ref: e5f403f
|
context_ref: e5f403f
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -55,16 +55,16 @@ T-242 把任意 `procurement_requests` 记录都视为“已进入采购流程
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] 仅含 `NEEDS_IMAGE` 且无参考图/任务的货运单可删除,草稿需求、商品和订单同事务消失。
|
- [x] 仅含 `NEEDS_IMAGE` 且无参考图/任务的货运单可删除,草稿需求、商品和订单同事务消失。
|
||||||
- [ ] `BLOCKED` 及无参考图的 `SOURCE_CHANGED` 草稿遵守相同删除规则。
|
- [x] `BLOCKED` 及无参考图的 `SOURCE_CHANGED` 草稿遵守相同删除规则。
|
||||||
- [ ] 已绑定参考图但未生成任务返回 409 `FREIGHT_ORDER_HAS_REFERENCE`,全部记录不变化。
|
- [x] 已绑定参考图但未生成任务返回 409 `FREIGHT_ORDER_HAS_REFERENCE`,全部记录不变化。
|
||||||
- [ ] 已生成采购任务返回 409 `FREIGHT_ORDER_HAS_PURCHASE_TASK`,全部采购链路不变化。
|
- [x] 已生成采购任务返回 409 `FREIGHT_ORDER_HAS_PURCHASE_TASK`,全部采购链路不变化。
|
||||||
- [ ] 同单同时存在两类保护关联时优先报告已生成采购任务。
|
- [x] 同单同时存在两类保护关联时优先报告已生成采购任务。
|
||||||
- [ ] 未知或不一致需求不能被静默删除。
|
- [x] 未知或不一致需求不能被静默删除。
|
||||||
- [ ] 采购任务列表仍只展示 `purchase_tasks`;货运详情准确说明需求尚未生成任务。
|
- [x] 采购任务列表仍只展示 `purchase_tasks`;货运详情准确说明需求尚未生成任务。
|
||||||
- [ ] Web 成功、参考图冲突和任务冲突反馈明确,CSRF、确认和响应式交互不回归。
|
- [x] Web 成功、参考图冲突和任务冲突反馈明确,CSRF、确认和响应式交互不回归。
|
||||||
- [ ] 删除不影响 ERP、同步历史、水位和 T-242 图片文件清理。
|
- [x] 删除不影响 ERP、同步历史、水位和 T-242 图片文件清理。
|
||||||
- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。
|
- [x] 标准 Go 测试、race、vet 和三个入口构建通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -79,3 +79,15 @@ T-242 把任意 `procurement_requests` 记录都视为“已进入采购流程
|
|||||||
- 2026-07-29:根据真实本地状态审计确认,删除冲突来自 `NEEDS_IMAGE` 采购需求,且其
|
- 2026-07-29:根据真实本地状态审计确认,删除冲突来自 `NEEDS_IMAGE` 采购需求,且其
|
||||||
`reference_asset_id`、`purchase_task_id` 均为空;采购任务列表只查 `purchase_tasks`
|
`reference_asset_id`、`purchase_task_id` 均为空;采购任务列表只查 `purchase_tasks`
|
||||||
的行为正确。本任务修复保护边界和反馈,不改变任务列表定义。
|
的行为正确。本任务修复保护边界和反馈,不改变任务列表定义。
|
||||||
|
- 2026-07-29:SQLite immediate 事务先检查 `purchase_task_id`,再检查
|
||||||
|
`reference_asset_id`;两者均不存在时只删除 `BLOCKED`、`NEEDS_IMAGE`、
|
||||||
|
`SOURCE_CHANGED` 草稿,并在删除商品前确认没有其他需求残留。任务冲突优先于参考图
|
||||||
|
冲突,未来或不一致状态保持 fail-closed;无需 schema migration。
|
||||||
|
- 2026-07-29:Admin API 保持原 DELETE 路由和幂等 204,保护分支分别返回
|
||||||
|
`FREIGHT_ORDER_HAS_REFERENCE`、`FREIGHT_ORDER_HAS_PURCHASE_TASK`。SSR 使用独立提示,
|
||||||
|
详情把 `NEEDS_IMAGE` 显示为“需要参考图,尚未生成任务”,确认文案明确草稿会被清理。
|
||||||
|
- 2026-07-29:仓储测试覆盖三种草稿状态、参考图保护、任务保护和任务优先级;usecase、
|
||||||
|
API、SSR 测试覆盖稳定错误映射及实际 `NEEDS_IMAGE` 删除。`go test ./...`、
|
||||||
|
`go test -race ./...`、`go vet ./...` 和 API、authctl、migrate 三个入口构建通过。
|
||||||
|
Playwright 连接脱敏隔离 Gin/SQLite,以桌面和 390×844 验证状态说明、确认弹窗、真实
|
||||||
|
登录及删除后空列表,控制台无警告或错误;未访问顺运宝 ERP。
|
||||||
|
|||||||
Reference in New Issue
Block a user