feat(t242): delete unused freight orders

This commit is contained in:
QiuSW
2026-07-29 16:10:45 +08:00
parent c80a5c26ed
commit e5f403ff7d
20 changed files with 915 additions and 27 deletions
@@ -0,0 +1,209 @@
package sqlite_test
import (
"context"
"errors"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
repository "cmroubao/backend-api/internal/repository/sqlite"
"cmroubao/backend-api/internal/usecase"
)
func TestDeleteFreightOrderIsScopedIdempotentAndReimportable(t *testing.T) {
db := openDatabase(t)
store, err := repository.New(db)
if err != nil {
t.Fatalf("repository.New() error = %v", err)
}
ctx := context.Background()
now := time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC)
userID := uuid(2420)
seedFreightUser(t, db, userID, now)
first := freightRun(2421, userID, now)
createAndStartFreightRun(t, store, first, "delete-source-1", "1")
batch := freightBatch(2422, "a", "b")
thumb := "190846474"
batch.Orders[0].Items[0].ProductThumbRef = &thumb
if err := store.CompleteFreightSync(
ctx,
first,
batch,
now.Add(time.Second),
); err != nil {
t.Fatalf("CompleteFreightSync() error = %v", err)
}
orders, err := store.ListFreightOrders(ctx, "local-admin", 10)
if err != nil || len(orders) != 1 {
t.Fatalf("orders = %+v, %v", orders, err)
}
orderID := orders[0].ID
detail, err := store.GetFreightOrder(ctx, "local-admin", orderID)
if err != nil || len(detail.Items) != 2 {
t.Fatalf("detail = %+v, %v", detail, err)
}
readyImage := domain.FreightItemImage{
CreatorSubject: "local-admin",
FreightOrderItemID: detail.Items[0].ID,
ProductThumbRef: thumb,
Status: domain.FreightItemImageReady,
MediaType: "image/jpeg",
SizeBytes: 100,
SHA256: repeatHex("c"),
StorageKey: "freight/delete-ready.jpg",
UpdatedAt: now.Add(2 * time.Second),
}
if _, err := store.SaveFreightItemImage(ctx, readyImage); err != nil {
t.Fatalf("SaveFreightItemImage() error = %v", err)
}
watermarkThrough := now.Add(30 * time.Minute)
dateRun := freightDateRun(
2423,
userID,
now.Add(time.Minute),
"2026-07-29",
"2026-07-29",
watermarkThrough,
)
createAndStartFreightRun(t, store, dateRun, "delete-watermark", "2")
if err := store.CompleteFreightDateSync(
ctx,
dateRun,
domain.FreightImportBatch{},
watermarkThrough,
now.Add(2*time.Minute),
); err != nil {
t.Fatalf("CompleteFreightDateSync() error = %v", err)
}
keys, err := store.DeleteFreightOrder(
ctx,
"another-admin",
orderID,
)
if err != nil || len(keys) != 0 {
t.Fatalf("cross-subject delete = %#v, %v", keys, err)
}
if _, err := store.GetFreightOrder(
ctx,
"local-admin",
orderID,
); err != nil {
t.Fatalf("cross-subject delete changed order: %v", err)
}
keys, err = store.DeleteFreightOrder(ctx, "local-admin", orderID)
if err != nil || len(keys) != 1 ||
keys[0] != readyImage.StorageKey {
t.Fatalf("DeleteFreightOrder() = %#v, %v", keys, err)
}
for table, want := range map[string]int{
"freight_orders": 0,
"freight_order_items": 0,
"freight_item_images": 0,
"erp_sync_runs": 2,
} {
var count int
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(
&count,
); err != nil || count != want {
t.Fatalf("%s count = %d, %v", table, count, err)
}
}
watermark, err := store.GetFreightSyncWatermark(ctx, "local-admin")
if err != nil || watermark == nil ||
!watermark.LastSuccessfulTo.Equal(watermarkThrough) {
t.Fatalf("watermark after delete = %+v, %v", watermark, err)
}
keys, err = store.DeleteFreightOrder(ctx, "local-admin", orderID)
if err != nil || len(keys) != 0 {
t.Fatalf("repeated delete = %#v, %v", keys, err)
}
reimport := freightRun(2424, userID, now.Add(3*time.Minute))
createAndStartFreightRun(t, store, reimport, "delete-reimport", "3")
if err := store.CompleteFreightSync(
ctx,
reimport,
freightBatch(2425, "a", "b"),
now.Add(4*time.Minute),
); err != nil {
t.Fatalf("reimport error = %v", err)
}
orders, err = store.ListFreightOrders(ctx, "local-admin", 10)
if err != nil || len(orders) != 1 || orders[0].ID == orderID ||
orders[0].Revision != 1 {
t.Fatalf("reimported orders = %+v, %v", orders, err)
}
}
func TestDeleteFreightOrderRejectsAnyProcurementReferenceAtomically(
t *testing.T,
) {
db := openDatabase(t)
store, _ := repository.New(db)
ctx := context.Background()
now := time.Date(2026, 7, 29, 9, 0, 0, 0, time.UTC)
userID := uuid(2430)
seedFreightUser(t, db, userID, now)
run := freightRun(2431, userID, now)
createAndStartFreightRun(t, store, run, "delete-conflict", "4")
if err := store.CompleteFreightSync(
ctx,
run,
freightBatch(2432, "a", "b"),
now.Add(time.Second),
); err != nil {
t.Fatalf("CompleteFreightSync() error = %v", err)
}
orders, _ := store.ListFreightOrders(ctx, "local-admin", 10)
detail, _ := store.GetFreightOrder(
ctx,
"local-admin",
orders[0].ID,
)
procurement, err := usecase.NewProcurementService(
store,
procurementClock{now: now.Add(2 * time.Second)},
&procurementIDs{next: 2440},
)
if err != nil {
t.Fatalf("NewProcurementService() error = %v", err)
}
if _, err := procurement.CreateRequest(
ctx,
usecase.CreateProcurementRequestCommand{
CreatorSubject: "local-admin",
ActorUserID: userID,
FreightOrderItemID: detail.Items[0].ID,
ConfirmProcurementNeeded: true,
},
); err != nil {
t.Fatalf("CreateRequest() error = %v", err)
}
keys, err := store.DeleteFreightOrder(
ctx,
"local-admin",
orders[0].ID,
)
if !errors.Is(err, usecase.ErrFreightOrderInUse) || len(keys) != 0 {
t.Fatalf("conflicting delete = %#v, %v", keys, err)
}
for table, want := range map[string]int{
"freight_orders": 1,
"freight_order_items": 2,
"procurement_requests": 1,
} {
var count int
if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(
&count,
); err != nil || count != want {
t.Fatalf("%s count = %d, %v", table, count, err)
}
}
}
@@ -617,6 +617,112 @@ func (store *Store) GetFreightOrder(
return domain.FreightOrderDetail{Order: order, Items: items}, nil
}
func (store *Store) DeleteFreightOrder(
ctx context.Context,
creatorSubject, orderID string,
) ([]string, error) {
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return nil, repositoryFailure(err)
}
defer tx.Rollback()
var storedOrderID string
err = tx.QueryRowContext(
ctx,
`SELECT id FROM freight_orders
WHERE creator_subject = ? AND id = ?`,
creatorSubject,
orderID,
).Scan(&storedOrderID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, repositoryFailure(err)
}
var inUse int
err = tx.QueryRowContext(
ctx,
`SELECT EXISTS (
SELECT 1
FROM freight_order_items AS item
JOIN procurement_requests AS request
ON request.freight_order_item_id = item.id
WHERE item.freight_order_id = ?
)`,
storedOrderID,
).Scan(&inUse)
if err != nil {
return nil, repositoryFailure(err)
}
if inUse != 0 {
return nil, usecase.ErrFreightOrderInUse
}
rows, err := tx.QueryContext(
ctx,
`SELECT image.storage_key
FROM freight_item_images AS image
JOIN freight_order_items AS item
ON item.id = image.freight_order_item_id
WHERE item.freight_order_id = ?
AND image.status = 'READY'
AND image.storage_key IS NOT NULL
ORDER BY image.storage_key`,
storedOrderID,
)
if err != nil {
return nil, repositoryFailure(err)
}
storageKeys := make([]string, 0)
for rows.Next() {
var storageKey string
if err := rows.Scan(&storageKey); err != nil {
rows.Close()
return nil, repositoryFailure(err)
}
storageKeys = append(storageKeys, storageKey)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, repositoryFailure(err)
}
if err := rows.Close(); err != nil {
return nil, repositoryFailure(err)
}
if _, err := tx.ExecContext(
ctx,
`DELETE FROM freight_order_items WHERE freight_order_id = ?`,
storedOrderID,
); err != nil {
return nil, repositoryFailure(err)
}
result, err := tx.ExecContext(
ctx,
`DELETE FROM freight_orders
WHERE creator_subject = ? AND id = ?`,
creatorSubject,
storedOrderID,
)
if err != nil {
return nil, repositoryFailure(err)
}
deleted, err := result.RowsAffected()
if err != nil {
return nil, repositoryFailure(err)
}
if deleted != 1 {
return nil, repositoryFailure(usecase.ErrRepositoryInvariant)
}
if err := tx.Commit(); err != nil {
return nil, repositoryFailure(err)
}
return storageKeys, nil
}
const freightSyncSelect = `SELECT
id, creator_subject, created_by_user_id, mode, order_number,
created_from, created_to, watermark_through, query_sha256, status,
@@ -75,6 +75,7 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
)
routes.GET("/api/v1/freight-orders", handler.listFreightOrders)
routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail)
routes.DELETE("/api/v1/freight-orders/:id", handler.deleteFreightOrder)
}
if services.FreightImages != nil {
routes.GET(
@@ -534,6 +534,92 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
}
deletedOrderID := listBody.Items[0].ID
deletedImageURL := detailBody.Items[0].ImageURL
deleteResponse := performAdminRequest(
t,
router,
http.MethodDelete,
"/api/v1/freight-orders/"+deletedOrderID,
"",
nil,
"",
)
if deleteResponse.Code != http.StatusNoContent ||
deleteResponse.Body.Len() != 0 ||
deleteResponse.Header().Get("Cache-Control") != "no-store" {
t.Fatalf(
"delete status/body/headers = %d / %s / %#v",
deleteResponse.Code,
deleteResponse.Body,
deleteResponse.Header(),
)
}
deletedDetail := performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/freight-orders/"+deletedOrderID,
"",
nil,
"",
)
deletedImage := performAdminRequest(
t,
router,
http.MethodGet,
deletedImageURL,
"",
nil,
"",
)
repeatedDelete := performAdminRequest(
t,
router,
http.MethodDelete,
"/api/v1/freight-orders/"+deletedOrderID,
"",
nil,
"",
)
if deletedDetail.Code != http.StatusNotFound ||
deletedImage.Code != http.StatusNotFound ||
repeatedDelete.Code != http.StatusNoContent {
t.Fatalf(
"after delete detail/image/replay = %d / %d / %d",
deletedDetail.Code,
deletedImage.Code,
repeatedDelete.Code,
)
}
reimport := performAdminRequest(
t,
router,
http.MethodPost,
"/api/v1/freight-syncs",
"application/json",
strings.NewReader(
`{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`,
),
"freight-sync-after-delete",
)
if reimport.Code != http.StatusCreated {
t.Fatalf("reimport status/body = %d / %s", reimport.Code, reimport.Body)
}
reimportedList := performAdminRequest(
t,
router,
http.MethodGet,
"/api/v1/freight-orders",
"",
nil,
"",
)
decodeResponse(t, reimportedList, &listBody)
if len(listBody.Items) != 1 ||
listBody.Items[0].ID == deletedOrderID {
t.Fatalf("reimported orders = %+v", listBody.Items)
}
}
func TestAdminFreightDateSyncAdvancesInspectableWatermark(t *testing.T) {
@@ -687,6 +773,26 @@ 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"`
@@ -255,6 +255,23 @@ func (h *adminHandlers) freightOrderDetail(ctx *gin.Context) {
ctx.JSON(http.StatusOK, response)
}
func (h *adminHandlers) deleteFreightOrder(ctx *gin.Context) {
err := h.services.Freight.DeleteOrder(
ctx.Request.Context(),
usecase.DeleteFreightOrderCommand{
CreatorSubject: localAdminSubject,
ActorUserID: adminActorUserID(ctx),
OrderID: ctx.Param("id"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.Status(http.StatusNoContent)
}
func freightSyncResponse(run domain.FreightSyncRun) gin.H {
return gin.H{
"id": run.ID,
@@ -89,6 +89,11 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
routes.POST(
"/freight/:id/delete",
SecurityHeaders(),
h.DeleteFreightOrder,
)
}
if _, ok := h.service.(ProcurementService); ok {
routes.POST(
@@ -527,6 +532,57 @@ func (h *Handler) FreightDetail(ctx *gin.Context) {
})
}
func (h *Handler) DeleteFreightOrder(ctx *gin.Context) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
h.renderError(
ctx,
http.StatusForbidden,
"请求已失效",
"请返回货运详情后重新操作。",
)
return
}
if ctx.PostForm("confirm_delete") != "1" {
h.renderError(
ctx,
http.StatusUnprocessableEntity,
"必须确认删除",
"请确认只删除本系统中的货运信息后再提交。",
)
return
}
orderID := strings.TrimSpace(ctx.Param("id"))
service := h.service.(FreightService)
err := service.DeleteFreightOrder(
ctx.Request.Context(),
DeleteFreightOrderInput{
ActorUserID: actorUserID(ctx.Request.Context()),
OrderID: orderID,
},
)
if errors.Is(err, ErrFreightOrderInUse) {
ctx.Redirect(
http.StatusSeeOther,
"/freight/"+pathEscape(orderID)+"?notice=delete-conflict",
)
return
}
if err != nil {
h.renderServiceError(
ctx,
err,
"货运单删除失败,请稍后重试。",
)
return
}
h.logEvent(
"freight_order_deleted order_id=" + orderID +
" actor_user_id=" + actorUserID(ctx.Request.Context()),
)
ctx.Redirect(http.StatusSeeOther, "/freight?notice=deleted")
}
func (h *Handler) CreateFreightProcurementRequest(ctx *gin.Context) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 16<<10)
if err := ctx.Request.ParseForm(); err != nil || !validCSRF(ctx) {
@@ -662,6 +718,10 @@ func freightNotice(value string) string {
return "需求状态或来源已变化,当前不能生成任务。"
case "import-succeeded":
return "ERP 货运单已同步,货运信息和商品明细已更新。"
case "deleted":
return "货运单已从本系统删除,不影响顺运宝 ERP。"
case "delete-conflict":
return "该货运单已进入采购流程,不能删除。"
default:
return ""
}
@@ -1301,6 +1301,102 @@ func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
}
}
func TestFreightDeleteRequiresConfirmationAndHandlesConflict(t *testing.T) {
service := &fakeFreightService{
fakeService: &fakeService{},
orderDetail: FreightOrderDetail{
Order: FreightOrder{
ID: testTaskID,
ExternalStockID: "12",
SourceCode: "SOURCE-12",
},
},
}
logged := make([]string, 0)
router := newTestRouterWithLogger(
t,
service,
func(value string) {
logged = append(logged, value)
},
)
detail := performRequest(
t,
router,
http.MethodGet,
"/freight/"+testTaskID,
nil,
"",
)
if detail.Code != http.StatusOK ||
!strings.Contains(detail.Body.String(), "删除本地货运单") ||
!strings.Contains(detail.Body.String(), "data-freight-delete-dialog") {
t.Fatalf("delete controls status/body = %d / %s", detail.Code, detail.Body)
}
cookie := csrfCookie(t, detail)
withoutCSRF := performRequest(
t,
router,
http.MethodPost,
"/freight/"+testTaskID+"/delete",
strings.NewReader("confirm_delete=1"),
"application/x-www-form-urlencoded",
)
if withoutCSRF.Code != http.StatusForbidden {
t.Fatalf("delete without CSRF status = %d", withoutCSRF.Code)
}
values := url.Values{
"csrf_token": {cookie.Value},
"confirm_delete": {"1"},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/"+testTaskID+"/delete",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") != "/freight?notice=deleted" ||
service.deleteInput.OrderID != testTaskID ||
len(logged) != 1 ||
!strings.Contains(logged[0], "freight_order_deleted order_id=") {
t.Fatalf(
"delete response/input/log = %d / %q / %+v / %#v",
response.Code,
response.Header().Get("Location"),
service.deleteInput,
logged,
)
}
service.deleteErr = ErrFreightOrderInUse
request = httptest.NewRequest(
http.MethodPost,
"/freight/"+testTaskID+"/delete",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/freight/"+testTaskID+"?notice=delete-conflict" ||
len(logged) != 1 {
t.Fatalf(
"conflicting delete status/location/log = %d / %q / %#v",
response.Code,
response.Header().Get("Location"),
logged,
)
}
}
func TestFreightPresentationFormatsMoneyAndImageStates(t *testing.T) {
value := int64(12905)
if got := formatMinorCurrency(&value, "TWD"); got != "TWD 129.05" {
@@ -1348,6 +1444,8 @@ type fakeFreightService struct {
*fakeService
orders []FreightOrder
orderDetail FreightOrderDetail
deleteInput DeleteFreightOrderInput
deleteErr error
sync FreightSync
createInput CreateFreightSyncInput
createResult FreightSync
@@ -1448,6 +1546,14 @@ func (service *fakeFreightService) GetFreightOrder(
return service.orderDetail, service.err
}
func (service *fakeFreightService) DeleteFreightOrder(
_ context.Context,
input DeleteFreightOrderInput,
) error {
service.deleteInput = input
return service.deleteErr
}
func (service *fakeFreightService) GetFreightSync(
context.Context,
string,
@@ -119,4 +119,23 @@
openCancel.focus();
});
}
const openFreightDelete = document.querySelector(
"[data-open-freight-delete]",
);
const freightDeleteDialog = document.querySelector(
"[data-freight-delete-dialog]",
);
if (openFreightDelete && freightDeleteDialog) {
openFreightDelete.addEventListener("click", () => {
freightDeleteDialog.showModal();
freightDeleteDialog.querySelector("[data-keep-freight-order]").focus();
});
freightDeleteDialog
.querySelector("[data-keep-freight-order]")
.addEventListener("click", () => {
freightDeleteDialog.close();
openFreightDelete.focus();
});
}
})();
@@ -13,7 +13,11 @@
<h1>{{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}货运详情{{end}}</h1>
<p class="subtitle">ERP ID:{{.Detail.Order.ExternalStockID}} · 来源版本 {{.Detail.Order.Revision}}</p>
</div>
<a class="button" href="/freight">返回列表</a>
<div class="title-actions freight-title-actions">
<a class="button" href="/freight">返回列表</a>
<button class="button danger-outline" type="button"
data-open-freight-delete>删除本地货运单</button>
</div>
</div>
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
<section class="detail-section" aria-labelledby="freight-source-title">
@@ -129,6 +133,38 @@
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
{{end}}
</section>
<noscript>
<section class="noscript-cancel" aria-labelledby="freight-delete-noscript-title">
<h2 id="freight-delete-noscript-title">删除本地货运单</h2>
<p>只删除本系统保存的信息,不影响顺运宝 ERP;已进入采购流程的货运单不能删除。</p>
<form method="post" action="/freight/{{pathPart .Detail.Order.ID}}/delete">
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<label class="confirm-line">
<input type="checkbox" name="confirm_delete" value="1" required>
<span>已确认删除本系统中的货运信息</span>
</label>
<button class="button danger" type="submit">确认删除</button>
</form>
</section>
</noscript>
<dialog class="confirm-dialog" data-freight-delete-dialog
aria-labelledby="freight-delete-title">
<h2 id="freight-delete-title">删除本地货运单?</h2>
<p>将删除本系统保存的 {{if .Detail.Order.SourceCode}}{{.Detail.Order.SourceCode}}{{else}}该货运单{{end}} 及商品图片,不影响顺运宝 ERP。已进入采购流程时系统会拒绝删除。</p>
<div class="dialog-actions">
<button class="button" type="button"
data-keep-freight-order>保留货运单</button>
<form method="post" action="/freight/{{pathPart .Detail.Order.ID}}/delete"
data-submit-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="confirm_delete" value="1">
<button class="button danger" type="submit" data-submit-button
data-loading-label="正在删除…">确认删除</button>
</form>
</div>
</dialog>
</main>
</body>
</html>
@@ -24,6 +24,7 @@ var (
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")
)
// Service is the application boundary required by the server-rendered admin UI.
@@ -40,6 +41,7 @@ type Service interface {
type FreightService interface {
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
DeleteFreightOrder(context.Context, DeleteFreightOrderInput) error
GetFreightSync(context.Context, string) (FreightSync, error)
GetFreightWatermark(context.Context) (*FreightWatermark, error)
CreateFreightSync(
@@ -135,6 +137,11 @@ type CreateFreightSyncInput struct {
SyncToNow bool
}
type DeleteFreightOrderInput struct {
ActorUserID string
OrderID string
}
type FreightOrder struct {
ID string
ExternalStockID string
@@ -192,6 +192,27 @@ func (adapter *UsecaseAdapter) GetFreightOrder(
}, nil
}
func (adapter *UsecaseAdapter) DeleteFreightOrder(
ctx context.Context,
input DeleteFreightOrderInput,
) error {
if adapter.freight == nil {
return ErrUnavailable
}
err := adapter.freight.DeleteOrder(
ctx,
usecase.DeleteFreightOrderCommand{
CreatorSubject: localAdminSubject,
ActorUserID: input.ActorUserID,
OrderID: input.OrderID,
},
)
if err != nil {
return mapUsecaseError(err)
}
return nil
}
func (adapter *UsecaseAdapter) CreateProcurementRequest(
ctx context.Context,
input CreateProcurementRequestInput,
@@ -933,6 +954,10 @@ func mapUsecaseError(err error) error {
return err
}
var public error
if typed.Code == "FREIGHT_ORDER_IN_USE" {
public = ErrFreightOrderInUse
return &adapterError{public: public, cause: err}
}
switch typed.Kind {
case usecase.ErrorKindInvalid:
if typed.Code == "TASK_CANCEL_INVALID" {
+8
View File
@@ -65,6 +65,7 @@ var (
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")
)
@@ -106,6 +107,13 @@ func wrapRepositoryError(err error) error {
"task state does not allow this operation",
err,
)
case errors.Is(err, ErrFreightOrderInUse):
return newError(
ErrorKindConflict,
"FREIGHT_ORDER_IN_USE",
"freight order is already used by procurement",
err,
)
case errors.Is(err, ErrRepositoryUnavailable):
result := newError(
ErrorKindUnavailable,
@@ -39,4 +39,5 @@ type FreightImageRepository interface {
type FreightImageCache interface {
CacheRun(context.Context, string, string) error
DeleteStoredImages([]string)
}
@@ -169,6 +169,20 @@ func (service *FreightImageService) deleteStoredImage(storageKey string) {
_ = service.store.Delete(ctx, storageKey)
}
func (service *FreightImageService) DeleteStoredImages(storageKeys []string) {
if len(storageKeys) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), freightImageCleanup)
defer cancel()
for _, storageKey := range storageKeys {
if ctx.Err() != nil {
return
}
_ = service.store.Delete(ctx, storageKey)
}
}
func (service *FreightImageService) OpenItemImage(
ctx context.Context,
creatorSubject, itemID string,
@@ -118,6 +118,30 @@ func TestFreightImageOpenDoesNotRevealUnavailableItems(t *testing.T) {
}
}
func TestFreightImageDeleteStoredImagesUsesIdempotentStoreCleanup(t *testing.T) {
store := &freightImageStoreFake{}
service, err := NewFreightImageService(
&freightImageRepositoryFake{},
&freightImageSourceFake{},
store,
fakeClock{},
)
if err != nil {
t.Fatalf("NewFreightImageService() error = %v", err)
}
service.DeleteStoredImages([]string{
"first/image.jpg",
"second/image.jpg",
})
store.mu.Lock()
defer store.mu.Unlock()
if len(store.deleted) != 2 ||
store.deleted[0] != "first/image.jpg" ||
store.deleted[1] != "second/image.jpg" {
t.Fatalf("deleted storage keys = %#v", store.deleted)
}
}
type freightImageRepositoryFake struct {
mu sync.Mutex
jobs []domain.FreightItemImageJob
@@ -58,4 +58,9 @@ type FreightRepository interface {
string,
string,
) (domain.FreightOrderDetail, error)
DeleteFreightOrder(
context.Context,
string,
string,
) ([]string, error)
}
@@ -69,6 +69,12 @@ type CreateFreightDateSyncCommand struct {
SyncToNow bool
}
type DeleteFreightOrderCommand struct {
CreatorSubject string
ActorUserID string
OrderID string
}
type CreateFreightSyncResult struct {
Run domain.FreightSyncRun
Replayed bool
@@ -701,6 +707,44 @@ func (service *FreightService) GetOrder(
return detail, nil
}
func (service *FreightService) DeleteOrder(
ctx context.Context,
command DeleteFreightOrderCommand,
) error {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
command.OrderID = strings.TrimSpace(command.OrderID)
fields := map[string]string{}
if command.CreatorSubject == "" {
fields["creator_subject"] = "is required"
}
if !isUUID(command.ActorUserID) {
fields["actor_user_id"] = "must be a UUID"
}
if !isUUID(command.OrderID) {
fields["order_id"] = "must be a UUID"
}
if len(fields) > 0 {
return invalidError(
"FREIGHT_DELETE_INVALID",
"freight order deletion is invalid",
fields,
)
}
storageKeys, err := service.repository.DeleteFreightOrder(
ctx,
command.CreatorSubject,
command.OrderID,
)
if err != nil {
return wrapRepositoryError(err)
}
if service.imageCache != nil {
service.imageCache.DeleteStoredImages(storageKeys)
}
return nil
}
func (service *FreightService) normalize(
source domain.FreightSourceBatch,
) (domain.FreightImportBatch, error) {
@@ -236,6 +236,7 @@ type recordingFreightImageCache struct {
creatorSubject string
runID string
err error
deleted []string
}
func (cache *recordingFreightImageCache) CacheRun(
@@ -248,6 +249,12 @@ func (cache *recordingFreightImageCache) CacheRun(
return cache.err
}
func (cache *recordingFreightImageCache) DeleteStoredImages(
storageKeys []string,
) {
cache.deleted = append(cache.deleted, storageKeys...)
}
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
repository := &syncTrackingRepository{}
source := &blockingOrderSource{}
@@ -378,6 +385,56 @@ func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncComman
}
}
func TestDeleteFreightOrderValidatesConflictAndCleansImages(t *testing.T) {
repository := &syncTrackingRepository{
deleteStorageKeys: []string{"first/image.jpg", "second/image.jpg"},
}
imageCache := &recordingFreightImageCache{}
service, err := NewFreightService(
repository,
&recordingDateSource{},
fakeClock{},
&sequenceIDs{},
time.Minute,
WithFreightImageCache(imageCache),
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
command := DeleteFreightOrderCommand{
CreatorSubject: " local-admin ",
ActorUserID: "00000000-0000-4000-8000-000000000099",
OrderID: "00000000-0000-4000-8000-000000000100",
}
if err := service.DeleteOrder(context.Background(), command); err != nil {
t.Fatalf("DeleteOrder() error = %v", err)
}
if repository.deleteCalls != 1 ||
repository.deleteCreatorSubject != "local-admin" ||
repository.deleteOrderID != command.OrderID {
t.Fatalf("delete repository call = %+v", repository)
}
if len(imageCache.deleted) != 2 ||
imageCache.deleted[0] != "first/image.jpg" ||
imageCache.deleted[1] != "second/image.jpg" {
t.Fatalf("deleted image keys = %#v", imageCache.deleted)
}
repository.deleteErr = ErrFreightOrderInUse
err = service.DeleteOrder(context.Background(), command)
assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_ORDER_IN_USE")
if len(imageCache.deleted) != 2 {
t.Fatalf("conflicting delete cleaned images = %#v", imageCache.deleted)
}
command.OrderID = "invalid"
err = service.DeleteOrder(context.Background(), command)
assertUsecaseError(t, err, ErrorKindInvalid, "FREIGHT_DELETE_INVALID")
if repository.deleteCalls != 2 {
t.Fatalf("invalid delete reached repository %d times", repository.deleteCalls)
}
}
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
source := &recordingDateSource{}
service := &FreightService{source: source}
@@ -581,12 +638,17 @@ func (*blockingOrderSource) QueryCreatedRange(
}
type syncTrackingRepository struct {
mu sync.Mutex
startCalls int
completeCalls int
failCalls int
failCode string
failContextErr error
mu sync.Mutex
startCalls int
completeCalls int
failCalls int
failCode string
failContextErr error
deleteCalls int
deleteCreatorSubject string
deleteOrderID string
deleteStorageKeys []string
deleteErr error
}
func (*syncTrackingRepository) CreateFreightSync(
@@ -682,6 +744,17 @@ func (*syncTrackingRepository) GetFreightOrder(
return domain.FreightOrderDetail{}, nil
}
func (repository *syncTrackingRepository) DeleteFreightOrder(
_ context.Context,
creatorSubject, orderID string,
) ([]string, error) {
repository.deleteCalls++
repository.deleteCreatorSubject = creatorSubject
repository.deleteOrderID = orderID
return append([]string(nil), repository.deleteStorageKeys...),
repository.deleteErr
}
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
source.ensureCalls++
return source.ensureErr
@@ -800,3 +873,11 @@ func (*dateCaptureRepository) GetFreightOrder(
) (domain.FreightOrderDetail, error) {
return domain.FreightOrderDetail{}, nil
}
func (*dateCaptureRepository) DeleteFreightOrder(
context.Context,
string,
string,
) ([]string, error) {
return nil, nil
}