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,
|
||||
) {
|
||||
db := openDatabase(t)
|
||||
store, _ := repository.New(db)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
|
||||
userID := uuid(2430)
|
||||
now := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC)
|
||||
userID := uuid(2470)
|
||||
seedFreightUser(t, db, userID, now)
|
||||
|
||||
run := freightRun(2431, userID, now)
|
||||
createAndStartFreightRun(t, store, run, "delete-conflict", "4")
|
||||
run := freightRun(2471, userID, now)
|
||||
createAndStartFreightRun(t, store, run, "delete-protected", "5")
|
||||
if err := store.CompleteFreightSync(
|
||||
ctx,
|
||||
run,
|
||||
freightBatch(2432, "a", "b"),
|
||||
freightBatch(2472, "a", "b"),
|
||||
now.Add(time.Second),
|
||||
); err != nil {
|
||||
t.Fatalf("CompleteFreightSync() error = %v", err)
|
||||
@@ -169,12 +272,12 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
||||
procurement, err := usecase.NewProcurementService(
|
||||
store,
|
||||
procurementClock{now: now.Add(2 * time.Second)},
|
||||
&procurementIDs{next: 2440},
|
||||
&procurementIDs{next: 2480},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewProcurementService() error = %v", err)
|
||||
}
|
||||
if _, err := procurement.CreateRequest(
|
||||
result, err := procurement.CreateRequest(
|
||||
ctx,
|
||||
usecase.CreateProcurementRequestCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
@@ -182,22 +285,75 @@ func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
|
||||
FreightOrderItemID: detail.Items[0].ID,
|
||||
ConfirmProcurementNeeded: true,
|
||||
},
|
||||
); err != nil {
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRequest() error = %v", err)
|
||||
}
|
||||
asset := domain.Asset{
|
||||
ID: uuid(2490),
|
||||
CreatorSubject: "local-admin",
|
||||
Purpose: domain.AssetPurposeTaskReference,
|
||||
MediaType: domain.NormalizedImageMediaType,
|
||||
SizeBytes: 100,
|
||||
SHA256: repeatHex("f"),
|
||||
StorageKey: "freight-delete/reference.jpg",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if _, _, err := store.CreateAssetIdempotent(
|
||||
ctx,
|
||||
asset,
|
||||
"freight-delete-reference",
|
||||
repeatHex("e"),
|
||||
); err != nil {
|
||||
t.Fatalf("CreateAssetIdempotent() error = %v", err)
|
||||
}
|
||||
if _, err := procurement.BindReference(
|
||||
ctx,
|
||||
usecase.BindProcurementReferenceCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: userID,
|
||||
RequestID: result.Request.ID,
|
||||
ImageAssetID: asset.ID,
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("BindReference() error = %v", err)
|
||||
}
|
||||
|
||||
keys, err := store.DeleteFreightOrder(
|
||||
ctx,
|
||||
"local-admin",
|
||||
orders[0].ID,
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrFreightOrderInUse) || len(keys) != 0 {
|
||||
t.Fatalf("conflicting delete = %#v, %v", keys, err)
|
||||
if !errors.Is(err, usecase.ErrFreightOrderHasReference) ||
|
||||
len(keys) != 0 {
|
||||
t.Fatalf("reference delete = %#v, %v", keys, err)
|
||||
}
|
||||
if _, err := procurement.CreateTask(
|
||||
ctx,
|
||||
usecase.CreateProcurementTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: userID,
|
||||
RequestID: result.Request.ID,
|
||||
IdempotencyKey: "freight-delete-task",
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("CreateTask() error = %v", err)
|
||||
}
|
||||
keys, err = store.DeleteFreightOrder(
|
||||
ctx,
|
||||
"local-admin",
|
||||
orders[0].ID,
|
||||
)
|
||||
if !errors.Is(err, usecase.ErrFreightOrderHasPurchaseTask) ||
|
||||
len(keys) != 0 {
|
||||
t.Fatalf("task delete = %#v, %v", keys, err)
|
||||
}
|
||||
for table, want := range map[string]int{
|
||||
"freight_orders": 1,
|
||||
"freight_order_items": 2,
|
||||
"procurement_requests": 1,
|
||||
"freight_orders": 1,
|
||||
"freight_order_items": 2,
|
||||
"procurement_requests": 1,
|
||||
"purchase_tasks": 1,
|
||||
"purchase_task_sources": 1,
|
||||
} {
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(
|
||||
|
||||
@@ -642,7 +642,63 @@ func (store *Store) DeleteFreightOrder(
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
|
||||
var inUse int
|
||||
var hasPurchaseTask int
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight_order_items AS item
|
||||
JOIN procurement_requests AS request
|
||||
ON request.freight_order_item_id = item.id
|
||||
WHERE item.freight_order_id = ?
|
||||
AND request.purchase_task_id IS NOT NULL
|
||||
)`,
|
||||
storedOrderID,
|
||||
).Scan(&hasPurchaseTask)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if hasPurchaseTask != 0 {
|
||||
return nil, usecase.ErrFreightOrderHasPurchaseTask
|
||||
}
|
||||
|
||||
var hasReference int
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight_order_items AS item
|
||||
JOIN procurement_requests AS request
|
||||
ON request.freight_order_item_id = item.id
|
||||
WHERE item.freight_order_id = ?
|
||||
AND request.reference_asset_id IS NOT NULL
|
||||
)`,
|
||||
storedOrderID,
|
||||
).Scan(&hasReference)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if hasReference != 0 {
|
||||
return nil, usecase.ErrFreightOrderHasReference
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(
|
||||
ctx,
|
||||
`DELETE FROM procurement_requests
|
||||
WHERE freight_order_item_id IN (
|
||||
SELECT id
|
||||
FROM freight_order_items
|
||||
WHERE freight_order_id = ?
|
||||
)
|
||||
AND reference_asset_id IS NULL
|
||||
AND purchase_task_id IS NULL
|
||||
AND status IN ('BLOCKED', 'NEEDS_IMAGE', 'SOURCE_CHANGED')`,
|
||||
storedOrderID,
|
||||
); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
|
||||
var remainingRequests int
|
||||
err = tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT EXISTS (
|
||||
@@ -653,12 +709,12 @@ func (store *Store) DeleteFreightOrder(
|
||||
WHERE item.freight_order_id = ?
|
||||
)`,
|
||||
storedOrderID,
|
||||
).Scan(&inUse)
|
||||
).Scan(&remainingRequests)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if inUse != 0 {
|
||||
return nil, usecase.ErrFreightOrderInUse
|
||||
if remainingRequests != 0 {
|
||||
return nil, repositoryFailure(usecase.ErrRepositoryInvariant)
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(
|
||||
|
||||
@@ -773,26 +773,6 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||
createRequest.Body,
|
||||
)
|
||||
}
|
||||
deleteInUse := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodDelete,
|
||||
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if deleteInUse.Code != http.StatusConflict ||
|
||||
!strings.Contains(
|
||||
deleteInUse.Body.String(),
|
||||
`"code":"FREIGHT_ORDER_IN_USE"`,
|
||||
) {
|
||||
t.Fatalf(
|
||||
"delete in-use status/body = %d / %s",
|
||||
deleteInUse.Code,
|
||||
deleteInUse.Body,
|
||||
)
|
||||
}
|
||||
var requestBody struct {
|
||||
Request struct {
|
||||
ID string `json:"id"`
|
||||
@@ -829,6 +809,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||
!strings.Contains(bind.Body.String(), `"status":"READY"`) {
|
||||
t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body)
|
||||
}
|
||||
deleteWithReference := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodDelete,
|
||||
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if deleteWithReference.Code != http.StatusConflict ||
|
||||
!strings.Contains(
|
||||
deleteWithReference.Body.String(),
|
||||
`"code":"FREIGHT_ORDER_HAS_REFERENCE"`,
|
||||
) {
|
||||
t.Fatalf(
|
||||
"delete with reference status/body = %d / %s",
|
||||
deleteWithReference.Code,
|
||||
deleteWithReference.Body,
|
||||
)
|
||||
}
|
||||
createTask := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
@@ -847,6 +847,26 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||
createTask.Body,
|
||||
)
|
||||
}
|
||||
deleteWithTask := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodDelete,
|
||||
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if deleteWithTask.Code != http.StatusConflict ||
|
||||
!strings.Contains(
|
||||
deleteWithTask.Body.String(),
|
||||
`"code":"FREIGHT_ORDER_HAS_PURCHASE_TASK"`,
|
||||
) {
|
||||
t.Fatalf(
|
||||
"delete with task status/body = %d / %s",
|
||||
deleteWithTask.Code,
|
||||
deleteWithTask.Body,
|
||||
)
|
||||
}
|
||||
var taskBody struct {
|
||||
Task struct {
|
||||
ID string `json:"id"`
|
||||
@@ -899,6 +919,90 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminFreightDeleteRemovesNeedsImageDraft(t *testing.T) {
|
||||
fixture := newAdminIntegrationFixture(t)
|
||||
createSync := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-syncs",
|
||||
"application/json",
|
||||
strings.NewReader(
|
||||
`{"mode":"ORDER_NUMBER","order_number":"DRAFT-DELETE"}`,
|
||||
),
|
||||
"draft-delete-freight-sync",
|
||||
)
|
||||
requireAdminStatus(t, createSync, http.StatusCreated)
|
||||
orders := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders",
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
var orderBody struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
decodeResponse(t, orders, &orderBody)
|
||||
detail := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
var detailBody struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
decodeResponse(t, detail, &detailBody)
|
||||
createRequest := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodPost,
|
||||
"/api/v1/freight-items/"+detailBody.Items[0].ID+
|
||||
"/procurement-request",
|
||||
"application/json",
|
||||
strings.NewReader(`{"confirm_procurement_needed":true}`),
|
||||
"",
|
||||
)
|
||||
if createRequest.Code != http.StatusCreated ||
|
||||
!strings.Contains(createRequest.Body.String(), `"status":"NEEDS_IMAGE"`) {
|
||||
t.Fatalf(
|
||||
"create request status/body = %d / %s",
|
||||
createRequest.Code,
|
||||
createRequest.Body,
|
||||
)
|
||||
}
|
||||
deleteDraft := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodDelete,
|
||||
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
requireAdminStatus(t, deleteDraft, http.StatusNoContent)
|
||||
deletedDetail := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-orders/"+orderBody.Items[0].ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
requireAdminStatus(t, deletedDetail, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||
fixture := newAdminIntegrationFixture(t)
|
||||
taskID, executionID, taskHash, firstKey, secondKey :=
|
||||
|
||||
@@ -561,10 +561,17 @@ func (h *Handler) DeleteFreightOrder(ctx *gin.Context) {
|
||||
OrderID: orderID,
|
||||
},
|
||||
)
|
||||
if errors.Is(err, ErrFreightOrderInUse) {
|
||||
if errors.Is(err, ErrFreightOrderHasPurchaseTask) {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=delete-conflict",
|
||||
"/freight/"+pathEscape(orderID)+"?notice=delete-task-conflict",
|
||||
)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrFreightOrderHasReference) {
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/freight/"+pathEscape(orderID)+"?notice=delete-reference-conflict",
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -720,8 +727,10 @@ func freightNotice(value string) string {
|
||||
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
||||
case "deleted":
|
||||
return "货运单已从本系统删除,不影响顺运宝 ERP。"
|
||||
case "delete-conflict":
|
||||
return "该货运单已进入采购流程,不能删除。"
|
||||
case "delete-task-conflict":
|
||||
return "该货运单已生成采购任务,不能删除。"
|
||||
case "delete-reference-conflict":
|
||||
return "该货运单已绑定采购参考图,不能删除。"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1330,7 +1330,11 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
|
||||
)
|
||||
if detail.Code != http.StatusOK ||
|
||||
!strings.Contains(detail.Body.String(), "删除本地货运单") ||
|
||||
!strings.Contains(detail.Body.String(), "data-freight-delete-dialog") {
|
||||
!strings.Contains(detail.Body.String(), "data-freight-delete-dialog") ||
|
||||
!strings.Contains(
|
||||
detail.Body.String(),
|
||||
"未绑定参考图的采购需求草稿",
|
||||
) {
|
||||
t.Fatalf("delete controls status/body = %d / %s", detail.Code, detail.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, detail)
|
||||
@@ -1374,7 +1378,7 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
service.deleteErr = ErrFreightOrderInUse
|
||||
service.deleteErr = ErrFreightOrderHasReference
|
||||
request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/"+testTaskID+"/delete",
|
||||
@@ -1386,10 +1390,32 @@ func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/"+testTaskID+"?notice=delete-conflict" ||
|
||||
"/freight/"+testTaskID+"?notice=delete-reference-conflict" ||
|
||||
len(logged) != 1 {
|
||||
t.Fatalf(
|
||||
"conflicting delete status/location/log = %d / %q / %#v",
|
||||
"reference conflict status/location/log = %d / %q / %#v",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
logged,
|
||||
)
|
||||
}
|
||||
|
||||
service.deleteErr = ErrFreightOrderHasPurchaseTask
|
||||
request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/freight/"+testTaskID+"/delete",
|
||||
strings.NewReader(values.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/"+testTaskID+"?notice=delete-task-conflict" ||
|
||||
len(logged) != 1 {
|
||||
t.Fatalf(
|
||||
"task conflict status/location/log = %d / %q / %#v",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
logged,
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
<noscript>
|
||||
<section class="noscript-cancel" aria-labelledby="freight-delete-noscript-title">
|
||||
<h2 id="freight-delete-noscript-title">删除本地货运单</h2>
|
||||
<p>只删除本系统保存的信息,不影响顺运宝 ERP;已进入采购流程的货运单不能删除。</p>
|
||||
<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">
|
||||
@@ -152,7 +152,7 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -8,23 +8,28 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("resource forbidden")
|
||||
ErrConflict = errors.New("resource conflict")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
ErrERPNotConfigured = errors.New("ERP is not configured")
|
||||
ErrERPSessionNeeded = errors.New("ERP session is required")
|
||||
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
||||
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
||||
ErrERPProtocol = errors.New("ERP protocol is invalid")
|
||||
ErrERPUnavailable = errors.New("ERP is unavailable")
|
||||
ErrERPFreightNotFound = errors.New("ERP freight order was not found")
|
||||
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
||||
ErrFreightSyncBusy = errors.New("freight sync is busy")
|
||||
ErrFreightSyncTimeout = errors.New("freight sync timed out")
|
||||
ErrFreightOrderInUse = errors.New("freight order is in use")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("resource forbidden")
|
||||
ErrConflict = errors.New("resource conflict")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
ErrERPNotConfigured = errors.New("ERP is not configured")
|
||||
ErrERPSessionNeeded = errors.New("ERP session is required")
|
||||
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
||||
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
||||
ErrERPProtocol = errors.New("ERP protocol is invalid")
|
||||
ErrERPUnavailable = errors.New("ERP is unavailable")
|
||||
ErrERPFreightNotFound = errors.New("ERP freight order was not found")
|
||||
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
||||
ErrFreightSyncBusy = errors.New("freight sync is busy")
|
||||
ErrFreightSyncTimeout = errors.New("freight sync timed out")
|
||||
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.
|
||||
|
||||
@@ -309,7 +309,7 @@ func procurementStatusLabel(
|
||||
case domain.ProcurementBlocked:
|
||||
return "资料阻塞"
|
||||
case domain.ProcurementNeedsImage:
|
||||
return "需要参考图"
|
||||
return "需要参考图,尚未生成任务"
|
||||
case domain.ProcurementReady:
|
||||
return "可以生成任务"
|
||||
case domain.ProcurementTaskCreated:
|
||||
@@ -954,8 +954,12 @@ func mapUsecaseError(err error) error {
|
||||
return err
|
||||
}
|
||||
var public error
|
||||
if typed.Code == "FREIGHT_ORDER_IN_USE" {
|
||||
public = ErrFreightOrderInUse
|
||||
if typed.Code == "FREIGHT_ORDER_HAS_PURCHASE_TASK" {
|
||||
public = ErrFreightOrderHasPurchaseTask
|
||||
return &adapterError{public: public, cause: err}
|
||||
}
|
||||
if typed.Code == "FREIGHT_ORDER_HAS_REFERENCE" {
|
||||
public = ErrFreightOrderHasReference
|
||||
return &adapterError{public: public, cause: err}
|
||||
}
|
||||
switch typed.Kind {
|
||||
|
||||
@@ -60,14 +60,19 @@ func invalidError(code, message string, fields map[string]string) *Error {
|
||||
}
|
||||
|
||||
var (
|
||||
ErrRepositoryNotFound = errors.New("repository resource not found")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key payload conflict")
|
||||
ErrSourceReferenceConflict = errors.New("source reference conflict")
|
||||
ErrAssetUnavailable = errors.New("asset is unavailable")
|
||||
ErrTaskStateConflict = errors.New("task state conflict")
|
||||
ErrFreightOrderInUse = errors.New("freight order is in use")
|
||||
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
||||
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
||||
ErrRepositoryNotFound = errors.New("repository resource not found")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key payload conflict")
|
||||
ErrSourceReferenceConflict = errors.New("source reference conflict")
|
||||
ErrAssetUnavailable = errors.New("asset is unavailable")
|
||||
ErrTaskStateConflict = errors.New("task state conflict")
|
||||
ErrFreightOrderHasReference = errors.New(
|
||||
"freight order has a procurement reference",
|
||||
)
|
||||
ErrFreightOrderHasPurchaseTask = errors.New(
|
||||
"freight order has a purchase task",
|
||||
)
|
||||
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
||||
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
||||
)
|
||||
|
||||
func wrapRepositoryError(err error) error {
|
||||
@@ -107,11 +112,18 @@ func wrapRepositoryError(err error) error {
|
||||
"task state does not allow this operation",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrFreightOrderInUse):
|
||||
case errors.Is(err, ErrFreightOrderHasPurchaseTask):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"FREIGHT_ORDER_IN_USE",
|
||||
"freight order is already used by procurement",
|
||||
"FREIGHT_ORDER_HAS_PURCHASE_TASK",
|
||||
"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,
|
||||
)
|
||||
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{
|
||||
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)
|
||||
}
|
||||
|
||||
repository.deleteErr = ErrFreightOrderInUse
|
||||
repository.deleteErr = ErrFreightOrderHasReference
|
||||
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 {
|
||||
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"
|
||||
err = service.DeleteOrder(context.Background(), command)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-29
|
||||
- 阶段:T-242 已完成受约束的本地货运单删除
|
||||
- 阶段:T-243 已完成货运删除的采购需求草稿边界修复
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||
均按文档提交、实现提交的顺序纳入历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
@@ -21,9 +21,9 @@
|
||||
`FAILED`,日期范围仍后台异步。HTTP `WriteTimeout` 为 70 秒。T-238 已兼容真实详情
|
||||
`created` 的分钟精度,仍按 Asia/Shanghai 严格解析并转 UTC。T-239 至 T-241 已保存
|
||||
`productSpec`、TWD 原始单价和数字图片引用,以有界并发缓存归一化 JPEG,并在鉴权
|
||||
本地路由、货运列表和详情中显示图片、状态与采购字段。T-242 允许 ADMIN 从详情页删除
|
||||
尚未进入采购流程的本地货运快照及缓存图;已有采购引用时原子拒绝,不修改 ERP、同步
|
||||
运行历史或增量水位,完整订单号可重新导入。
|
||||
本地路由、货运列表和详情中显示图片、状态与采购字段。T-242/T-243 允许 ADMIN 从
|
||||
详情页删除本地货运快照、缓存图以及无参考图/任务的采购需求草稿;已绑定参考图或已生成
|
||||
采购任务时分别原子拒绝,不修改 ERP、同步运行历史或增量水位,完整订单号可重新导入。
|
||||
- ERP Go 迁移:T-225 已用脱敏 fixture 固定 `internal/platform/shunyunbao` 的 header、
|
||||
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226
|
||||
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
||||
@@ -45,7 +45,7 @@
|
||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:T-219 Android Debug/Release 单元测试与构建和根 `init.ps1` 通过;
|
||||
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 会话预检、完整单号、日期分页去重、
|
||||
详情 allowlist 和稳定错误码覆盖;根 `init.ps1` 的 Android 测试/Debug APK 与 Go 标准
|
||||
验证也通过,均未访问真实 ERP;
|
||||
@@ -62,8 +62,9 @@
|
||||
图片状态和采购操作。Playwright 已在 1440、768、390、375 四档宽度验证无横向溢出、
|
||||
破图、遮挡和控制台错误。
|
||||
- 货运本地删除:详情页二次确认后调用 CSRF 保护的 SSR POST,JSON API 提供幂等 DELETE;
|
||||
删除事务覆盖全部历史商品并阻止任何采购引用,提交后以有界预算清理缓存 JPEG。真实
|
||||
隔离服务已验证成功后列表为空、图片 404 和文件消失;顺运宝 ERP 未被调用。
|
||||
删除事务覆盖全部历史商品并清理无引用的 `BLOCKED`、`NEEDS_IMAGE`、`SOURCE_CHANGED`
|
||||
采购草稿;已绑定参考图或采购任务分别返回稳定冲突,任务优先。提交后以有界预算清理
|
||||
缓存 JPEG。真实隔离服务已验证草稿随货运单删除后列表为空;顺运宝 ERP 未被调用。
|
||||
- 鉴权:bcrypt 密码、8 小时管理 session、1 小时 App access token 和设备 secret
|
||||
均不明文落库;设备首次绑定原子化,禁用/过期/撤销每次请求重新检查;管理/App
|
||||
登录各自按来源地址执行内存有界限流,账号和设备支持 `authctl` 启停
|
||||
|
||||
+23
-11
@@ -4,7 +4,7 @@ title: 允许货运删除清理未完成采购需求草稿
|
||||
phase: 2
|
||||
deps:
|
||||
- T-242
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-29
|
||||
context_ref: e5f403f
|
||||
work_branch: null
|
||||
@@ -55,16 +55,16 @@ T-242 把任意 `procurement_requests` 记录都视为“已进入采购流程
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [ ] 仅含 `NEEDS_IMAGE` 且无参考图/任务的货运单可删除,草稿需求、商品和订单同事务消失。
|
||||
- [ ] `BLOCKED` 及无参考图的 `SOURCE_CHANGED` 草稿遵守相同删除规则。
|
||||
- [ ] 已绑定参考图但未生成任务返回 409 `FREIGHT_ORDER_HAS_REFERENCE`,全部记录不变化。
|
||||
- [ ] 已生成采购任务返回 409 `FREIGHT_ORDER_HAS_PURCHASE_TASK`,全部采购链路不变化。
|
||||
- [ ] 同单同时存在两类保护关联时优先报告已生成采购任务。
|
||||
- [ ] 未知或不一致需求不能被静默删除。
|
||||
- [ ] 采购任务列表仍只展示 `purchase_tasks`;货运详情准确说明需求尚未生成任务。
|
||||
- [ ] Web 成功、参考图冲突和任务冲突反馈明确,CSRF、确认和响应式交互不回归。
|
||||
- [ ] 删除不影响 ERP、同步历史、水位和 T-242 图片文件清理。
|
||||
- [ ] 标准 Go 测试、race、vet 和三个入口构建通过。
|
||||
- [x] 仅含 `NEEDS_IMAGE` 且无参考图/任务的货运单可删除,草稿需求、商品和订单同事务消失。
|
||||
- [x] `BLOCKED` 及无参考图的 `SOURCE_CHANGED` 草稿遵守相同删除规则。
|
||||
- [x] 已绑定参考图但未生成任务返回 409 `FREIGHT_ORDER_HAS_REFERENCE`,全部记录不变化。
|
||||
- [x] 已生成采购任务返回 409 `FREIGHT_ORDER_HAS_PURCHASE_TASK`,全部采购链路不变化。
|
||||
- [x] 同单同时存在两类保护关联时优先报告已生成采购任务。
|
||||
- [x] 未知或不一致需求不能被静默删除。
|
||||
- [x] 采购任务列表仍只展示 `purchase_tasks`;货运详情准确说明需求尚未生成任务。
|
||||
- [x] Web 成功、参考图冲突和任务冲突反馈明确,CSRF、确认和响应式交互不回归。
|
||||
- [x] 删除不影响 ERP、同步历史、水位和 T-242 图片文件清理。
|
||||
- [x] 标准 Go 测试、race、vet 和三个入口构建通过。
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -79,3 +79,15 @@ T-242 把任意 `procurement_requests` 记录都视为“已进入采购流程
|
||||
- 2026-07-29:根据真实本地状态审计确认,删除冲突来自 `NEEDS_IMAGE` 采购需求,且其
|
||||
`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