fix(t243): allow deleting unreferenced procurement drafts

This commit is contained in:
QiuSW
2026-07-29 16:45:25 +08:00
parent 62eee74a70
commit 24eef29076
12 changed files with 504 additions and 102 deletions
@@ -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>
+22 -17
View File
@@ -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 {
+23 -11
View File
@@ -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)
}
}
+8 -7
View File
@@ -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
View File
@@ -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。