Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e20457b6db | ||
|
|
b5f45b87a5 |
@@ -10,28 +10,32 @@ var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status
|
|||||||
type AuthorizationStatus string
|
type AuthorizationStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AuthorizationStatusActive AuthorizationStatus = "ACTIVE"
|
AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY"
|
||||||
AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED"
|
AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED"
|
||||||
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
|
AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED"
|
||||||
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
|
AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING"
|
||||||
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
|
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
|
||||||
AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
|
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
|
||||||
|
AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED"
|
||||||
|
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OrderAuthorization struct {
|
type OrderAuthorization struct {
|
||||||
ID string
|
ID string
|
||||||
TaskID string
|
TaskID string
|
||||||
TaskVersion int
|
SpecTrialID string
|
||||||
StartKey string
|
Version int
|
||||||
GoodsID string
|
GoodsID string
|
||||||
SKUColor string
|
SKUColor string
|
||||||
SKUSize string
|
SKUSize string
|
||||||
Quantity int
|
Quantity int
|
||||||
TotalPriceCap string
|
AuthorizedUnitPrice string
|
||||||
Status AuthorizationStatus
|
TotalPriceCap string
|
||||||
CreatedBy string
|
Note *string
|
||||||
CreatedAt time.Time
|
Status AuthorizationStatus
|
||||||
ExpiresAt time.Time
|
CreatedBy string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
|
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
|
||||||
@@ -50,15 +54,25 @@ func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
|
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
|
||||||
AuthorizationStatusActive: {
|
AuthorizationStatusPendingDelivery: {
|
||||||
AuthorizationStatusClaimed: {},
|
AuthorizationStatusDelivered: {},
|
||||||
AuthorizationStatusExpired: {},
|
AuthorizationStatusSuperseded: {},
|
||||||
AuthorizationStatusAbandoned: {},
|
AuthorizationStatusExpired: {},
|
||||||
},
|
},
|
||||||
AuthorizationStatusClaimed: {
|
AuthorizationStatusDelivered: {
|
||||||
AuthorizationStatusFenced: {},
|
AuthorizationStatusAcknowledged: {},
|
||||||
AuthorizationStatusExpired: {},
|
AuthorizationStatusSuperseded: {},
|
||||||
AuthorizationStatusAbandoned: {},
|
AuthorizationStatusExpired: {},
|
||||||
|
},
|
||||||
|
AuthorizationStatusAcknowledged: {
|
||||||
|
AuthorizationStatusExecuting: {},
|
||||||
|
AuthorizationStatusSuperseded: {},
|
||||||
|
AuthorizationStatusExpired: {},
|
||||||
|
},
|
||||||
|
AuthorizationStatusExecuting: {
|
||||||
|
AuthorizationStatusFenced: {},
|
||||||
|
AuthorizationStatusSuperseded: {},
|
||||||
|
AuthorizationStatusExpired: {},
|
||||||
},
|
},
|
||||||
AuthorizationStatusFenced: {
|
AuthorizationStatusFenced: {
|
||||||
AuthorizationStatusConsumed: {},
|
AuthorizationStatusConsumed: {},
|
||||||
|
|||||||
@@ -14,17 +14,19 @@ func TestAuthorizationTransitions(t *testing.T) {
|
|||||||
next domain.AuthorizationStatus
|
next domain.AuthorizationStatus
|
||||||
allowed bool
|
allowed bool
|
||||||
}{
|
}{
|
||||||
{"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true},
|
{"deliver", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusDelivered, true},
|
||||||
{"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true},
|
{"acknowledge", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusAcknowledged, true},
|
||||||
|
{"execute", domain.AuthorizationStatusAcknowledged, domain.AuthorizationStatusExecuting, true},
|
||||||
|
{"fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusFenced, true},
|
||||||
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
|
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
|
||||||
{"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true},
|
{"expire pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusExpired, true},
|
||||||
{"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true},
|
{"supersede pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusSuperseded, true},
|
||||||
{"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true},
|
{"expire before fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusExpired, true},
|
||||||
{"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true},
|
{"supersede before fence", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusSuperseded, true},
|
||||||
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
|
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
|
||||||
{"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false},
|
{"fenced authorization cannot be superseded", domain.AuthorizationStatusFenced, domain.AuthorizationStatusSuperseded, false},
|
||||||
{"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false},
|
{"fenced authorization cannot be delivered again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusDelivered, false},
|
||||||
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false},
|
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusDelivered, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrInvalidAttemptTransition = errors.New("invalid purchase attempt status transition")
|
|
||||||
|
|
||||||
// AttemptStatus 只描述单趟领取的可恢复执行。真实提交结果独立由唯一围栏记录调和。
|
|
||||||
type AttemptStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AttemptStatusClaimed AttemptStatus = "CLAIMED"
|
|
||||||
AttemptStatusOrdering AttemptStatus = "ORDERING"
|
|
||||||
AttemptStatusFailed AttemptStatus = "FAILED"
|
|
||||||
AttemptStatusFenced AttemptStatus = "FENCED"
|
|
||||||
AttemptStatusAbandoned AttemptStatus = "ABANDONED"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AttemptFailureCode 是服务端可审计的固定失败摘要,不能承载页面正文或其他自由文本。
|
|
||||||
type AttemptFailureCode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AttemptFailureAuthorizationExpired AttemptFailureCode = "AUTHORIZATION_EXPIRED"
|
|
||||||
AttemptFailureLeaseLost AttemptFailureCode = "LEASE_LOST"
|
|
||||||
AttemptFailureGate1Rejected AttemptFailureCode = "GATE_1_REJECTED"
|
|
||||||
AttemptFailureQuantityMismatch AttemptFailureCode = "QUANTITY_MISMATCH"
|
|
||||||
AttemptFailureGate2Rejected AttemptFailureCode = "GATE_2_REJECTED"
|
|
||||||
AttemptFailureGate3Rejected AttemptFailureCode = "GATE_3_REJECTED"
|
|
||||||
AttemptFailureFenceRejected AttemptFailureCode = "FENCE_REJECTED"
|
|
||||||
AttemptFailureSafeAborted AttemptFailureCode = "SAFE_ABORTED"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PurchaseAttempt struct {
|
|
||||||
ID string
|
|
||||||
TaskID string
|
|
||||||
AuthorizationID string
|
|
||||||
ClaimGeneration int
|
|
||||||
Status AttemptStatus
|
|
||||||
Gate1UnitPrice *string
|
|
||||||
Gate2UnitPrice *string
|
|
||||||
QuantityRead *int
|
|
||||||
ConfirmAmount *string
|
|
||||||
FailureCode *AttemptFailureCode
|
|
||||||
StartedAt time.Time
|
|
||||||
FinishedAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanTransitionTo 只允许围栏前的领取恢复为安全失败;围栏后不再提供回退或重试路径。
|
|
||||||
func (status AttemptStatus) CanTransitionTo(next AttemptStatus) bool {
|
|
||||||
_, allowed := attemptTransitions[status][next]
|
|
||||||
return allowed
|
|
||||||
}
|
|
||||||
|
|
||||||
func TransitionAttempt(current, next AttemptStatus) (AttemptStatus, error) {
|
|
||||||
if !current.CanTransitionTo(next) {
|
|
||||||
return current, ErrInvalidAttemptTransition
|
|
||||||
}
|
|
||||||
return next, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var attemptTransitions = map[AttemptStatus]map[AttemptStatus]struct{}{
|
|
||||||
AttemptStatusClaimed: {
|
|
||||||
AttemptStatusOrdering: {},
|
|
||||||
AttemptStatusFailed: {},
|
|
||||||
AttemptStatusAbandoned: {},
|
|
||||||
},
|
|
||||||
AttemptStatusOrdering: {
|
|
||||||
AttemptStatusFenced: {},
|
|
||||||
AttemptStatusFailed: {},
|
|
||||||
AttemptStatusAbandoned: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package domain_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"cmbuyer/admin/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPurchaseAttemptTransitions(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
current domain.AttemptStatus
|
|
||||||
next domain.AttemptStatus
|
|
||||||
allowed bool
|
|
||||||
}{
|
|
||||||
{domain.AttemptStatusClaimed, domain.AttemptStatusOrdering, true},
|
|
||||||
{domain.AttemptStatusOrdering, domain.AttemptStatusFenced, true},
|
|
||||||
{domain.AttemptStatusOrdering, domain.AttemptStatusFailed, true},
|
|
||||||
{domain.AttemptStatusFenced, domain.AttemptStatusOrdering, false},
|
|
||||||
{domain.AttemptStatusFenced, domain.AttemptStatusAbandoned, false},
|
|
||||||
{domain.AttemptStatus("UNKNOWN"), domain.AttemptStatusOrdering, false},
|
|
||||||
} {
|
|
||||||
got, err := domain.TransitionAttempt(test.current, test.next)
|
|
||||||
if test.allowed {
|
|
||||||
if err != nil || got != test.next {
|
|
||||||
t.Fatalf("TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !errors.Is(err, domain.ErrInvalidAttemptTransition) || got != test.current {
|
|
||||||
t.Fatalf("invalid TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type SpecTrial struct {
|
||||||
|
ID string
|
||||||
|
TaskID string
|
||||||
|
Attempt int
|
||||||
|
ProductTitle string
|
||||||
|
SelectedColor string
|
||||||
|
SelectedSize string
|
||||||
|
UnitPrice string
|
||||||
|
TotalPrice string
|
||||||
|
EvidenceSHA256 string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@@ -17,17 +17,17 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type OrderSubmission struct {
|
type OrderSubmission struct {
|
||||||
ID string
|
ID string
|
||||||
TaskID string
|
TaskID string
|
||||||
AuthorizationID string
|
AuthorizationID string
|
||||||
AttemptID string
|
CommandID string
|
||||||
Status SubmissionStatus
|
DryRunID string
|
||||||
Gate1UnitPrice string
|
Status SubmissionStatus
|
||||||
Gate2UnitPrice string
|
VerifiedUnitPrice string
|
||||||
QuantityRead int
|
QuantityRead int
|
||||||
ConfirmAmount string
|
ConfirmPageAmount string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
ResolvedAt *time.Time
|
ResolvedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
|
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ func TestSubmissionTransitions(t *testing.T) {
|
|||||||
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
|
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
|
||||||
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
|
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
|
||||||
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
|
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
|
||||||
{"manual resolution cannot create a second submission", domain.SubmissionStatusManualResolved, domain.SubmissionStatusFenced, false},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ const (
|
|||||||
TaskStatusDraft TaskStatus = "DRAFT"
|
TaskStatusDraft TaskStatus = "DRAFT"
|
||||||
TaskStatusPending TaskStatus = "PENDING"
|
TaskStatusPending TaskStatus = "PENDING"
|
||||||
TaskStatusClaimed TaskStatus = "CLAIMED"
|
TaskStatusClaimed TaskStatus = "CLAIMED"
|
||||||
|
TaskStatusRunning TaskStatus = "RUNNING"
|
||||||
|
TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION"
|
||||||
|
TaskStatusPendingRetrial TaskStatus = "PENDING_RETRIAL"
|
||||||
|
TaskStatusAuthorized TaskStatus = "AUTHORIZED"
|
||||||
TaskStatusOrdering TaskStatus = "ORDERING"
|
TaskStatusOrdering TaskStatus = "ORDERING"
|
||||||
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
|
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
|
||||||
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
|
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
|
||||||
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
|
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
|
||||||
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
|
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
|
||||||
TaskStatusFailed TaskStatus = "FAILED"
|
|
||||||
TaskStatusCanceled TaskStatus = "CANCELED"
|
TaskStatusCanceled TaskStatus = "CANCELED"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,32 +68,35 @@ func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
|
|||||||
|
|
||||||
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
|
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
|
||||||
TaskStatusDraft: {
|
TaskStatusDraft: {
|
||||||
TaskStatusPending: {},
|
TaskStatusPending: {},
|
||||||
TaskStatusCanceled: {},
|
|
||||||
},
|
},
|
||||||
TaskStatusPending: {
|
TaskStatusPending: {
|
||||||
TaskStatusClaimed: {},
|
TaskStatusClaimed: {},
|
||||||
TaskStatusDraft: {},
|
},
|
||||||
TaskStatusCanceled: {},
|
TaskStatusPendingRetrial: {
|
||||||
|
TaskStatusClaimed: {},
|
||||||
},
|
},
|
||||||
TaskStatusClaimed: {
|
TaskStatusClaimed: {
|
||||||
|
TaskStatusRunning: {},
|
||||||
|
TaskStatusPending: {},
|
||||||
|
},
|
||||||
|
TaskStatusRunning: {
|
||||||
|
TaskStatusWaitingConfirmation: {},
|
||||||
|
TaskStatusNeedsManual: {},
|
||||||
|
},
|
||||||
|
TaskStatusWaitingConfirmation: {
|
||||||
|
TaskStatusCanceled: {},
|
||||||
|
TaskStatusAuthorized: {},
|
||||||
|
},
|
||||||
|
TaskStatusAuthorized: {
|
||||||
TaskStatusOrdering: {},
|
TaskStatusOrdering: {},
|
||||||
TaskStatusDraft: {},
|
|
||||||
},
|
},
|
||||||
TaskStatusOrdering: {
|
TaskStatusOrdering: {
|
||||||
TaskStatusNeedsManual: {},
|
TaskStatusNeedsManual: {},
|
||||||
TaskStatusWaitingPayment: {},
|
TaskStatusWaitingPayment: {},
|
||||||
TaskStatusReconciliationRequired: {},
|
TaskStatusReconciliationRequired: {},
|
||||||
},
|
},
|
||||||
TaskStatusNeedsManual: {
|
|
||||||
TaskStatusDraft: {},
|
|
||||||
TaskStatusCanceled: {},
|
|
||||||
},
|
|
||||||
TaskStatusWaitingPayment: {
|
TaskStatusWaitingPayment: {
|
||||||
TaskStatusSucceeded: {},
|
TaskStatusSucceeded: {},
|
||||||
},
|
},
|
||||||
TaskStatusReconciliationRequired: {
|
|
||||||
TaskStatusWaitingPayment: {},
|
|
||||||
TaskStatusFailed: {},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,24 +14,22 @@ func TestTaskTransitions(t *testing.T) {
|
|||||||
next domain.TaskStatus
|
next domain.TaskStatus
|
||||||
allowed bool
|
allowed bool
|
||||||
}{
|
}{
|
||||||
{"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true},
|
{"start trial", domain.TaskStatusDraft, domain.TaskStatusPending, true},
|
||||||
{"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true},
|
{"claim trial", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
|
||||||
{"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
|
{"claim retrial", domain.TaskStatusPendingRetrial, domain.TaskStatusClaimed, true},
|
||||||
{"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true},
|
{"start trial execution", domain.TaskStatusClaimed, domain.TaskStatusRunning, true},
|
||||||
{"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true},
|
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusPending, true},
|
||||||
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true},
|
{"trial completes", domain.TaskStatusRunning, domain.TaskStatusWaitingConfirmation, true},
|
||||||
{"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
{"trial needs manual review", domain.TaskStatusRunning, domain.TaskStatusNeedsManual, true},
|
||||||
|
{"authorize confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusAuthorized, true},
|
||||||
|
{"reject confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusCanceled, true},
|
||||||
|
{"start authorized order leg", domain.TaskStatusAuthorized, domain.TaskStatusOrdering, true},
|
||||||
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
|
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
|
||||||
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
||||||
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
|
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
|
||||||
{"manual review resets draft", domain.TaskStatusNeedsManual, domain.TaskStatusDraft, true},
|
|
||||||
{"manual review cancels before fence", domain.TaskStatusNeedsManual, domain.TaskStatusCanceled, true},
|
|
||||||
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
|
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
|
||||||
{"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true},
|
{"cannot skip trial", domain.TaskStatusDraft, domain.TaskStatusAuthorized, false},
|
||||||
{"reconcile confirms failed", domain.TaskStatusReconciliationRequired, domain.TaskStatusFailed, true},
|
{"trial cannot enter order leg", domain.TaskStatusRunning, domain.TaskStatusOrdering, false},
|
||||||
{"cannot skip authorization", domain.TaskStatusDraft, domain.TaskStatusOrdering, false},
|
|
||||||
{"ordering cannot return pending", domain.TaskStatusOrdering, domain.TaskStatusPending, false},
|
|
||||||
{"ordering cannot bypass manual review to draft", domain.TaskStatusOrdering, domain.TaskStatusDraft, false},
|
|
||||||
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
|
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
|
||||||
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
|
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ package migrations_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmbuyer/admin/internal/migrations"
|
"cmbuyer/admin/internal/migrations"
|
||||||
@@ -16,8 +13,6 @@ import (
|
|||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const migrationTime = "2026-08-04T00:00:00Z"
|
|
||||||
|
|
||||||
func TestUpDownAndIdempotence(t *testing.T) {
|
func TestUpDownAndIdempotence(t *testing.T) {
|
||||||
database := openTestDatabase(t)
|
database := openTestDatabase(t)
|
||||||
directory := migrationDirectory(t)
|
directory := migrationDirectory(t)
|
||||||
@@ -26,325 +21,173 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
|||||||
if err := migrations.Up(context, database, directory); err != nil {
|
if err := migrations.Up(context, database, directory); err != nil {
|
||||||
t.Fatalf("apply migrations: %v", err)
|
t.Fatalf("apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 2)
|
assertVersion(t, database, 1)
|
||||||
assertTableExists(t, database, "tasks", true)
|
assertTableExists(t, database, "tasks", true)
|
||||||
assertTableExists(t, database, "spec_trials", false)
|
assertTableExists(t, database, "spec_trials", true)
|
||||||
assertTableExists(t, database, "order_authorizations", true)
|
assertTableExists(t, database, "order_authorizations", true)
|
||||||
assertTableExists(t, database, "purchase_attempts", true)
|
|
||||||
assertTableExists(t, database, "order_submissions", true)
|
assertTableExists(t, database, "order_submissions", true)
|
||||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
|
||||||
|
|
||||||
if err := migrations.Up(context, database, directory); err != nil {
|
if err := migrations.Up(context, database, directory); err != nil {
|
||||||
t.Fatalf("reapply migrations: %v", err)
|
t.Fatalf("reapply migrations: %v", err)
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 2)
|
assertVersion(t, database, 1)
|
||||||
|
|
||||||
if err := migrations.Down(context, database, directory); err != nil {
|
if err := migrations.Down(context, database, directory); err != nil {
|
||||||
t.Fatalf("roll back v2 migration: %v", err)
|
t.Fatalf("roll back migration: %v", err)
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 1)
|
assertVersion(t, database, 0)
|
||||||
assertTableExists(t, database, "spec_trials", true)
|
assertTableExists(t, database, "tasks", false)
|
||||||
assertTableExists(t, database, "purchase_attempts", false)
|
assertTableExists(t, database, "spec_trials", false)
|
||||||
assertTableExists(t, database, "single_pass_downgrade_guard", false)
|
assertTableExists(t, database, "order_authorizations", false)
|
||||||
|
assertTableExists(t, database, "order_submissions", false)
|
||||||
|
|
||||||
if err := migrations.Up(context, database, directory); err != nil {
|
if err := migrations.Up(context, database, directory); err != nil {
|
||||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
t.Fatalf("apply migration after rollback: %v", err)
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 2)
|
assertVersion(t, database, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
func TestSchemaConstraints(t *testing.T) {
|
||||||
database := openTestDatabase(t)
|
|
||||||
migrateToV1(t, database)
|
|
||||||
if _, err := database.Exec(`
|
|
||||||
INSERT INTO tasks (
|
|
||||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
|
||||||
reference_asset_id, status, version, created_at, updated_at
|
|
||||||
) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50',
|
|
||||||
'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("insert v1 draft: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
|
||||||
t.Fatalf("upgrade v1 draft: %v", err)
|
|
||||||
}
|
|
||||||
assertVersion(t, database, 2)
|
|
||||||
var got struct {
|
|
||||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
|
||||||
quantity, version int
|
|
||||||
}
|
|
||||||
if err := database.QueryRow(`SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'`).Scan(
|
|
||||||
&got.id, &got.source, &got.sourceRef, &got.title, &got.goodsID, &got.color, &got.size, &got.quantity, &got.maxPrice, &got.assetID, &got.status, &got.version, &got.created, &got.updated,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("read upgraded draft: %v", err)
|
|
||||||
}
|
|
||||||
if got != (struct {
|
|
||||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
|
||||||
quantity, version int
|
|
||||||
}{"draft-one", "MANUAL", "source-ref", "title", "goods", "white", "XL", "80.50", "asset-id", "DRAFT", "2026-08-03T00:00:00Z", "2026-08-03T01:00:00Z", 2, 7}) {
|
|
||||||
t.Fatalf("upgraded draft changed: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpgradeRejectsLegacyExecutionDataAtomically(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setup func(*testing.T, *sql.DB)
|
|
||||||
}{
|
|
||||||
{"non-draft task", func(t *testing.T, database *sql.DB) {
|
|
||||||
insertV1Task(t, database, "pending", "MANUAL", "PENDING", "1.00")
|
|
||||||
}},
|
|
||||||
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "excel", "EXCEL", "DRAFT", "1.00") }},
|
|
||||||
{"invalid v2 money", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "zero", "MANUAL", "DRAFT", "0.00") }},
|
|
||||||
{"third decimal place", func(t *testing.T, database *sql.DB) {
|
|
||||||
insertV1Task(t, database, "third-decimal", "MANUAL", "DRAFT", "1.234")
|
|
||||||
}},
|
|
||||||
{"spec trial", func(t *testing.T, database *sql.DB) {
|
|
||||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
|
||||||
insertV1SpecTrial(t, database, "trial", "task")
|
|
||||||
}},
|
|
||||||
{"authorization", func(t *testing.T, database *sql.DB) {
|
|
||||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
|
||||||
insertV1SpecTrial(t, database, "trial", "task")
|
|
||||||
insertV1Authorization(t, database, "auth", "task", "trial")
|
|
||||||
}},
|
|
||||||
{"submission", func(t *testing.T, database *sql.DB) {
|
|
||||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
|
||||||
insertV1SpecTrial(t, database, "trial", "task")
|
|
||||||
insertV1Authorization(t, database, "auth", "task", "trial")
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )`, migrationTime); err != nil {
|
|
||||||
t.Fatalf("insert v1 submission: %v", err)
|
|
||||||
}
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
database := openTestDatabase(t)
|
|
||||||
migrateToV1(t, database)
|
|
||||||
test.setup(t, database)
|
|
||||||
before := v1RowCount(t, database)
|
|
||||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
|
|
||||||
t.Fatal("unsafe legacy data upgraded successfully")
|
|
||||||
}
|
|
||||||
assertVersion(t, database, 1)
|
|
||||||
assertTableExists(t, database, "spec_trials", true)
|
|
||||||
assertTableExists(t, database, "purchase_attempts", false)
|
|
||||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
|
||||||
if after := v1RowCount(t, database); after != before {
|
|
||||||
t.Fatalf("v1 data changed after rejection: before=%d after=%d", before, after)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
|
||||||
database := openTestDatabase(t)
|
database := openTestDatabase(t)
|
||||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||||
t.Fatalf("apply migrations: %v", err)
|
t.Fatalf("apply migrations: %v", err)
|
||||||
}
|
}
|
||||||
for _, column := range []struct{ table, name string }{
|
|
||||||
|
for _, column := range []struct {
|
||||||
|
table string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
{"tasks", "max_total_price"},
|
{"tasks", "max_total_price"},
|
||||||
|
{"spec_trials", "unit_price"},
|
||||||
|
{"spec_trials", "total_price"},
|
||||||
|
{"order_authorizations", "authorized_unit_price"},
|
||||||
{"order_authorizations", "total_price_cap"},
|
{"order_authorizations", "total_price_cap"},
|
||||||
{"purchase_attempts", "gate1_unit_price"},
|
{"order_submissions", "verified_unit_price"},
|
||||||
{"purchase_attempts", "gate2_unit_price"},
|
{"order_submissions", "confirm_page_amount"},
|
||||||
{"purchase_attempts", "confirm_amount"},
|
|
||||||
{"order_submissions", "gate1_unit_price"},
|
|
||||||
{"order_submissions", "gate2_unit_price"},
|
|
||||||
{"order_submissions", "confirm_amount"},
|
|
||||||
} {
|
} {
|
||||||
assertColumnType(t, database, column.table, column.name, "TEXT")
|
assertColumnType(t, database, column.table, column.name, "TEXT")
|
||||||
}
|
}
|
||||||
for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} {
|
|
||||||
var count int
|
if _, err := database.Exec(`
|
||||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil {
|
INSERT INTO tasks (
|
||||||
t.Fatalf("search schema for %s: %v", legacy, err)
|
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||||
}
|
status, created_at, updated_at
|
||||||
if count != 0 {
|
) VALUES ('bad-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 0, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||||
t.Fatalf("legacy identifier %q remains in v2 schema", legacy)
|
`); err == nil {
|
||||||
}
|
t.Fatal("insert task with quantity 0 succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
if _, err := database.Exec(`
|
||||||
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
|
INSERT INTO tasks (
|
||||||
for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} {
|
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)`, "bad-price-"+strconv.Itoa(index), value, migrationTime, migrationTime); err == nil {
|
status, created_at, updated_at
|
||||||
t.Fatalf("invalid total price %q succeeded", value)
|
) VALUES ('bad-price', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80..00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||||
}
|
`); err == nil {
|
||||||
|
t.Fatal("insert task with malformed decimal price succeeded")
|
||||||
}
|
}
|
||||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)`, migrationTime, migrationTime); err == nil {
|
|
||||||
t.Fatal("unknown task status succeeded")
|
|
||||||
}
|
|
||||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
|
||||||
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
|
||||||
t.Fatal("third decimal authorization cap succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
|
||||||
t.Fatal("unknown authorization status succeeded")
|
|
||||||
}
|
|
||||||
insertV2Authorization(t, database, "auth-one-b", "task-one", 2, "start-one-b")
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
|
||||||
t.Fatal("duplicate task version authorization succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("attempt using another task authorization succeeded")
|
|
||||||
}
|
|
||||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
|
||||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("third decimal gate price succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("unknown attempt status succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("unknown failure code succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("submission using another task authorization succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("submission combining another same-task authorization and attempt succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("unknown submission status succeeded")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("third decimal submission price succeeded")
|
|
||||||
}
|
|
||||||
insertV2Submission(t, database, "submission-one", "task-one", "auth-one", "attempt-one")
|
|
||||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
|
||||||
t.Fatal("second submission for fenced authorization succeeded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
if _, err := database.Exec(`
|
||||||
tests := []struct {
|
INSERT INTO tasks (
|
||||||
name string
|
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||||
setup func(*testing.T, *sql.DB)
|
status, created_at, updated_at
|
||||||
}{
|
) VALUES ('fractional-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 1.5, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||||
{"authorization", func(t *testing.T, database *sql.DB) {
|
`); err == nil {
|
||||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
t.Fatal("insert task with fractional quantity succeeded")
|
||||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
|
||||||
}},
|
|
||||||
{"attempt", func(t *testing.T, database *sql.DB) {
|
|
||||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
|
||||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
|
||||||
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
|
|
||||||
}},
|
|
||||||
{"submission", func(t *testing.T, database *sql.DB) {
|
|
||||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
|
||||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
|
||||||
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
|
|
||||||
insertV2Submission(t, database, "submission", "task", "auth", "attempt")
|
|
||||||
}},
|
|
||||||
{"non-draft task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "pending", "MANUAL", "PENDING") }},
|
|
||||||
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "excel", "EXCEL", "DRAFT") }},
|
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
database := openTestDatabase(t)
|
|
||||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
|
||||||
t.Fatalf("apply migrations: %v", err)
|
|
||||||
}
|
|
||||||
test.setup(t, database)
|
|
||||||
before := v2RowCount(t, database)
|
|
||||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
|
||||||
t.Fatal("unsafe v2 data downgraded successfully")
|
|
||||||
}
|
|
||||||
assertVersion(t, database, 2)
|
|
||||||
assertTableExists(t, database, "purchase_attempts", true)
|
|
||||||
assertTableExists(t, database, "spec_trials", false)
|
|
||||||
assertTableExists(t, database, "single_pass_downgrade_guard", false)
|
|
||||||
if after := v2RowCount(t, database); after != before {
|
|
||||||
t.Fatalf("v2 data changed after rejected downgrade: before=%d after=%d", before, after)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateToV1(t *testing.T, database *sql.DB) {
|
if _, err := database.Exec(`
|
||||||
t.Helper()
|
INSERT INTO tasks (
|
||||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||||
t.Fatalf("apply v1: %v", err)
|
status, created_at, updated_at
|
||||||
|
) VALUES ('trailing-decimal', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert task with trailing decimal point succeeded")
|
||||||
}
|
}
|
||||||
assertVersion(t, database, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
|
if _, err := database.Exec(`
|
||||||
t.Helper()
|
INSERT INTO tasks (
|
||||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
|
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||||
t.Fatalf("insert v1 task: %v", err)
|
status, created_at, updated_at
|
||||||
|
) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.00', 'UNKNOWN', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert task with invalid status succeeded")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
|
insertTask(t, database, "task-one")
|
||||||
t.Helper()
|
insertTask(t, database, "task-two")
|
||||||
if _, err := database.Exec(`INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil {
|
if _, err := database.Exec(`
|
||||||
t.Fatalf("insert v1 spec trial: %v", err)
|
INSERT INTO spec_trials (
|
||||||
|
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
|
||||||
|
total_price, evidence_sha256, created_at
|
||||||
|
) VALUES ('orphan-trial', 'missing-task', 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert spec trial without task succeeded")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) {
|
insertSpecTrial(t, database, "trial-one", "task-one")
|
||||||
t.Helper()
|
insertSpecTrial(t, database, "trial-two", "task-two")
|
||||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil {
|
if _, err := database.Exec(`
|
||||||
t.Fatalf("insert v1 authorization: %v", err)
|
INSERT INTO order_authorizations (
|
||||||
|
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||||
|
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||||
|
) VALUES ('authorization-cross-task', 'task-one', 'trial-two', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert authorization with a spec trial from another task succeeded")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) {
|
insertAuthorization(t, database, "authorization-one", "task-one", "trial-one", 1)
|
||||||
t.Helper()
|
insertAuthorization(t, database, "authorization-task-two", "task-two", "trial-two", 1)
|
||||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil {
|
if _, err := database.Exec(`
|
||||||
t.Fatalf("insert v2 task: %v", err)
|
INSERT INTO order_submissions (
|
||||||
|
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||||
|
quantity_read, confirm_page_amount, created_at
|
||||||
|
) VALUES ('submission-cross-task', 'task-one', 'authorization-task-two', 'command-cross-task', 'dry-run-cross-task', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert submission with an authorization from another task succeeded")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) {
|
if _, err := database.Exec(`
|
||||||
t.Helper()
|
INSERT INTO order_authorizations (
|
||||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, id, taskID, version, startKey, migrationTime, migrationTime); err != nil {
|
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||||
t.Fatalf("insert v2 authorization: %v", err)
|
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||||
|
) VALUES ('authorization-duplicate', 'task-one', 'trial-one', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert authorization with duplicate task version succeeded")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) {
|
insertSubmission(t, database, "submission-one", "authorization-one", "command-one")
|
||||||
t.Helper()
|
if _, err := database.Exec(`
|
||||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, id, taskID, authorizationID, generation, migrationTime); err != nil {
|
INSERT INTO order_submissions (
|
||||||
t.Fatalf("insert v2 attempt: %v", err)
|
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||||
|
quantity_read, confirm_page_amount, created_at
|
||||||
|
) VALUES ('submission-duplicate-auth', 'task-one', 'authorization-one', 'command-two', 'dry-run-two', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert submission with duplicate authorization succeeded")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) {
|
insertAuthorization(t, database, "authorization-two", "task-one", "trial-one", 2)
|
||||||
t.Helper()
|
if _, err := database.Exec(`
|
||||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, id, taskID, authorizationID, attemptID, migrationTime); err != nil {
|
INSERT INTO order_submissions (
|
||||||
t.Fatalf("insert v2 submission: %v", err)
|
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||||
|
quantity_read, confirm_page_amount, created_at
|
||||||
|
) VALUES ('submission-duplicate-command', 'task-one', 'authorization-two', 'command-one', 'dry-run-three', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||||
|
`); err == nil {
|
||||||
|
t.Fatal("insert submission with duplicate command succeeded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func v1RowCount(t *testing.T, database *sql.DB) int {
|
|
||||||
t.Helper()
|
|
||||||
var count int
|
|
||||||
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
|
|
||||||
t.Fatalf("count v1 rows: %v", err)
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func v2RowCount(t *testing.T, database *sql.DB) int {
|
|
||||||
t.Helper()
|
|
||||||
var count int
|
|
||||||
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
|
|
||||||
t.Fatalf("count v2 rows: %v", err)
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func openTestDatabase(t *testing.T) *sql.DB {
|
func openTestDatabase(t *testing.T) *sql.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
|
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open test database: %v", err)
|
t.Fatalf("open test database: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = database.Close() })
|
t.Cleanup(func() {
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Errorf("close test database: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return database
|
return database
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +197,7 @@ func migrationDirectory(t *testing.T) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("locate migration test source")
|
t.Fatal("locate migration test source")
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,12 +234,50 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestV2MigrationSQLDoesNotDisableForeignKeys(t *testing.T) {
|
func insertTask(t *testing.T, database *sql.DB, id string) {
|
||||||
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql"))
|
t.Helper()
|
||||||
if err != nil {
|
if _, err := database.Exec(`
|
||||||
t.Fatalf("read migration: %v", err)
|
INSERT INTO tasks (
|
||||||
|
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||||
|
status, created_at, updated_at
|
||||||
|
) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 2, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||||
|
`, id); err != nil {
|
||||||
|
t.Fatalf("insert task: %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
|
}
|
||||||
t.Fatal("migration disables foreign keys")
|
|
||||||
|
func insertSpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := database.Exec(`
|
||||||
|
INSERT INTO spec_trials (
|
||||||
|
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
|
||||||
|
total_price, evidence_sha256, created_at
|
||||||
|
) VALUES (?, ?, 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
|
||||||
|
`, id, taskID); err != nil {
|
||||||
|
t.Fatalf("insert spec trial: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertAuthorization(t *testing.T, database *sql.DB, id, taskID, specTrialID string, version int) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := database.Exec(`
|
||||||
|
INSERT INTO order_authorizations (
|
||||||
|
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||||
|
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||||
|
`, id, taskID, specTrialID, version); err != nil {
|
||||||
|
t.Fatalf("insert authorization: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSubmission(t *testing.T, database *sql.DB, id, authorizationID, commandID string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := database.Exec(`
|
||||||
|
INSERT INTO order_submissions (
|
||||||
|
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||||
|
quantity_read, confirm_page_amount, created_at
|
||||||
|
) VALUES (?, 'task-one', ?, ?, 'dry-run-one', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||||
|
`, id, authorizationID, commandID); err != nil {
|
||||||
|
t.Fatalf("insert submission: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
-- +goose Up
|
|
||||||
-- v1 的试选/锁旧价记录无法安全推断为单趟执行事实。先在同一事务中拒绝它们,
|
|
||||||
-- 避免删除审计数据后再尝试猜测映射。
|
|
||||||
CREATE TABLE single_pass_upgrade_guard (
|
|
||||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO single_pass_upgrade_guard (valid)
|
|
||||||
SELECT CASE WHEN
|
|
||||||
(SELECT COUNT(*) FROM spec_trials) = 0
|
|
||||||
AND (SELECT COUNT(*) FROM order_authorizations) = 0
|
|
||||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
|
||||||
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
|
|
||||||
-- v2 的金额边界是严格正数;不把 v1 中不能无损纳入该边界的数据悄悄改写。
|
|
||||||
AND (SELECT COUNT(*) FROM tasks WHERE
|
|
||||||
max_total_price = ''
|
|
||||||
OR max_total_price GLOB '*[^0-9.]*'
|
|
||||||
OR length(max_total_price) - length(replace(max_total_price, '.', '')) > 1
|
|
||||||
OR max_total_price = '.'
|
|
||||||
OR (instr(max_total_price, '.') > 0 AND (
|
|
||||||
instr(max_total_price, '.') = 1
|
|
||||||
OR length(max_total_price) = instr(max_total_price, '.')
|
|
||||||
OR length(max_total_price) - instr(max_total_price, '.') > 2
|
|
||||||
))
|
|
||||||
OR replace(replace(max_total_price, '.', ''), '0', '') = ''
|
|
||||||
) = 0
|
|
||||||
THEN 1 ELSE 0 END;
|
|
||||||
|
|
||||||
DROP TABLE single_pass_upgrade_guard;
|
|
||||||
|
|
||||||
ALTER TABLE tasks RENAME TO tasks_v1;
|
|
||||||
DROP TABLE order_submissions;
|
|
||||||
DROP TABLE order_authorizations;
|
|
||||||
DROP TABLE spec_trials;
|
|
||||||
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
|
|
||||||
source_ref TEXT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
goods_id TEXT NOT NULL,
|
|
||||||
sku_color TEXT NOT NULL,
|
|
||||||
sku_size TEXT NOT NULL,
|
|
||||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
|
||||||
max_total_price TEXT NOT NULL CHECK (
|
|
||||||
max_total_price <> ''
|
|
||||||
AND max_total_price NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
|
|
||||||
AND max_total_price <> '.'
|
|
||||||
AND (instr(max_total_price, '.') = 0 OR (
|
|
||||||
instr(max_total_price, '.') > 1
|
|
||||||
AND length(max_total_price) > instr(max_total_price, '.')
|
|
||||||
AND length(max_total_price) - instr(max_total_price, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(max_total_price, '.', ''), '0', '') <> ''
|
|
||||||
),
|
|
||||||
reference_asset_id TEXT,
|
|
||||||
status TEXT NOT NULL CHECK (status IN (
|
|
||||||
'DRAFT', 'PENDING', 'CLAIMED', 'ORDERING', 'NEEDS_MANUAL', 'WAITING_PAYMENT',
|
|
||||||
'RECONCILIATION_REQUIRED', 'SUCCEEDED', 'FAILED', 'CANCELED'
|
|
||||||
)),
|
|
||||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO tasks (
|
|
||||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
|
||||||
reference_asset_id, status, version, created_at, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
|
||||||
reference_asset_id, status, version, created_at, updated_at
|
|
||||||
FROM tasks_v1;
|
|
||||||
|
|
||||||
DROP TABLE tasks_v1;
|
|
||||||
|
|
||||||
CREATE TABLE order_authorizations (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
||||||
task_version INTEGER NOT NULL CHECK (task_version > 0 AND typeof(task_version) = 'integer'),
|
|
||||||
start_key TEXT NOT NULL,
|
|
||||||
goods_id TEXT NOT NULL,
|
|
||||||
sku_color TEXT NOT NULL,
|
|
||||||
sku_size TEXT NOT NULL,
|
|
||||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
|
||||||
total_price_cap TEXT NOT NULL CHECK (
|
|
||||||
total_price_cap <> ''
|
|
||||||
AND total_price_cap NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
|
|
||||||
AND total_price_cap <> '.'
|
|
||||||
AND (instr(total_price_cap, '.') = 0 OR (
|
|
||||||
instr(total_price_cap, '.') > 1
|
|
||||||
AND length(total_price_cap) > instr(total_price_cap, '.')
|
|
||||||
AND length(total_price_cap) - instr(total_price_cap, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(total_price_cap, '.', ''), '0', '') <> ''
|
|
||||||
),
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'CLAIMED', 'FENCED', 'CONSUMED', 'EXPIRED', 'ABANDONED')),
|
|
||||||
created_by TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
UNIQUE (task_id, task_version),
|
|
||||||
UNIQUE (start_key, task_id),
|
|
||||||
UNIQUE (task_id, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE purchase_attempts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
authorization_id TEXT NOT NULL,
|
|
||||||
claim_generation INTEGER NOT NULL CHECK (claim_generation > 0 AND typeof(claim_generation) = 'integer'),
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('CLAIMED', 'ORDERING', 'FAILED', 'FENCED', 'ABANDONED')),
|
|
||||||
gate1_unit_price TEXT CHECK (
|
|
||||||
gate1_unit_price IS NULL OR (
|
|
||||||
gate1_unit_price <> ''
|
|
||||||
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
|
|
||||||
AND gate1_unit_price <> '.'
|
|
||||||
AND (instr(gate1_unit_price, '.') = 0 OR (
|
|
||||||
instr(gate1_unit_price, '.') > 1
|
|
||||||
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
|
|
||||||
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
|
|
||||||
)
|
|
||||||
),
|
|
||||||
gate2_unit_price TEXT CHECK (
|
|
||||||
gate2_unit_price IS NULL OR (
|
|
||||||
gate2_unit_price <> ''
|
|
||||||
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
|
|
||||||
AND gate2_unit_price <> '.'
|
|
||||||
AND (instr(gate2_unit_price, '.') = 0 OR (
|
|
||||||
instr(gate2_unit_price, '.') > 1
|
|
||||||
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
|
|
||||||
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
|
|
||||||
)
|
|
||||||
),
|
|
||||||
quantity_read INTEGER CHECK (quantity_read IS NULL OR (quantity_read > 0 AND typeof(quantity_read) = 'integer')),
|
|
||||||
confirm_amount TEXT CHECK (
|
|
||||||
confirm_amount IS NULL OR (
|
|
||||||
confirm_amount <> ''
|
|
||||||
AND confirm_amount NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
|
|
||||||
AND confirm_amount <> '.'
|
|
||||||
AND (instr(confirm_amount, '.') = 0 OR (
|
|
||||||
instr(confirm_amount, '.') > 1
|
|
||||||
AND length(confirm_amount) > instr(confirm_amount, '.')
|
|
||||||
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
|
|
||||||
)
|
|
||||||
),
|
|
||||||
failure_code TEXT CHECK (failure_code IS NULL OR failure_code IN (
|
|
||||||
'AUTHORIZATION_EXPIRED', 'LEASE_LOST', 'GATE_1_REJECTED', 'QUANTITY_MISMATCH',
|
|
||||||
'GATE_2_REJECTED', 'GATE_3_REJECTED', 'FENCE_REJECTED', 'SAFE_ABORTED'
|
|
||||||
)),
|
|
||||||
started_at TEXT NOT NULL,
|
|
||||||
finished_at TEXT,
|
|
||||||
UNIQUE (task_id, claim_generation),
|
|
||||||
UNIQUE (task_id, id),
|
|
||||||
UNIQUE (task_id, authorization_id, id),
|
|
||||||
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE order_submissions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
authorization_id TEXT NOT NULL,
|
|
||||||
attempt_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
|
|
||||||
gate1_unit_price TEXT NOT NULL CHECK (
|
|
||||||
gate1_unit_price <> ''
|
|
||||||
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
|
|
||||||
AND gate1_unit_price <> '.'
|
|
||||||
AND (instr(gate1_unit_price, '.') = 0 OR (
|
|
||||||
instr(gate1_unit_price, '.') > 1
|
|
||||||
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
|
|
||||||
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
|
|
||||||
),
|
|
||||||
gate2_unit_price TEXT NOT NULL CHECK (
|
|
||||||
gate2_unit_price <> ''
|
|
||||||
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
|
|
||||||
AND gate2_unit_price <> '.'
|
|
||||||
AND (instr(gate2_unit_price, '.') = 0 OR (
|
|
||||||
instr(gate2_unit_price, '.') > 1
|
|
||||||
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
|
|
||||||
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
|
|
||||||
),
|
|
||||||
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
|
|
||||||
confirm_amount TEXT NOT NULL CHECK (
|
|
||||||
confirm_amount <> ''
|
|
||||||
AND confirm_amount NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
|
|
||||||
AND confirm_amount <> '.'
|
|
||||||
AND (instr(confirm_amount, '.') = 0 OR (
|
|
||||||
instr(confirm_amount, '.') > 1
|
|
||||||
AND length(confirm_amount) > instr(confirm_amount, '.')
|
|
||||||
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
|
|
||||||
))
|
|
||||||
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
|
|
||||||
),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
resolved_at TEXT,
|
|
||||||
UNIQUE (authorization_id),
|
|
||||||
UNIQUE (attempt_id),
|
|
||||||
FOREIGN KEY (task_id, authorization_id, attempt_id) REFERENCES purchase_attempts(task_id, authorization_id, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- 只有尚未产生任何单趟授权或执行事实的纯 MANUAL/DRAFT 数据才能无损回到 v1。
|
|
||||||
CREATE TABLE single_pass_downgrade_guard (
|
|
||||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO single_pass_downgrade_guard (valid)
|
|
||||||
SELECT CASE WHEN
|
|
||||||
(SELECT COUNT(*) FROM order_authorizations) = 0
|
|
||||||
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
|
|
||||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
|
||||||
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
|
|
||||||
THEN 1 ELSE 0 END;
|
|
||||||
|
|
||||||
DROP TABLE single_pass_downgrade_guard;
|
|
||||||
|
|
||||||
ALTER TABLE tasks RENAME TO tasks_v2;
|
|
||||||
DROP TABLE order_submissions;
|
|
||||||
DROP TABLE purchase_attempts;
|
|
||||||
DROP TABLE order_authorizations;
|
|
||||||
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
|
|
||||||
source_ref TEXT,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
goods_id TEXT NOT NULL,
|
|
||||||
sku_color TEXT NOT NULL,
|
|
||||||
sku_size TEXT NOT NULL,
|
|
||||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
|
||||||
max_total_price TEXT NOT NULL CHECK (
|
|
||||||
max_total_price <> ''
|
|
||||||
AND max_total_price NOT GLOB '*[^0-9.]*'
|
|
||||||
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
|
|
||||||
AND max_total_price <> '.'
|
|
||||||
AND (instr(max_total_price, '.') = 0 OR (
|
|
||||||
instr(max_total_price, '.') > 1
|
|
||||||
AND length(max_total_price) > instr(max_total_price, '.')
|
|
||||||
AND length(max_total_price) - instr(max_total_price, '.') <= 2
|
|
||||||
))
|
|
||||||
),
|
|
||||||
reference_asset_id TEXT,
|
|
||||||
status TEXT NOT NULL CHECK (status IN (
|
|
||||||
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
|
|
||||||
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
|
|
||||||
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
|
|
||||||
)),
|
|
||||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
INSERT INTO tasks (
|
|
||||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
|
||||||
reference_asset_id, status, version, created_at, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
|
||||||
reference_asset_id, status, version, created_at, updated_at
|
|
||||||
FROM tasks_v2;
|
|
||||||
|
|
||||||
DROP TABLE tasks_v2;
|
|
||||||
|
|
||||||
CREATE TABLE spec_trials (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
||||||
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
|
|
||||||
product_title TEXT NOT NULL,
|
|
||||||
selected_color TEXT NOT NULL,
|
|
||||||
selected_size TEXT NOT NULL,
|
|
||||||
unit_price TEXT NOT NULL CHECK (unit_price <> '' AND unit_price NOT GLOB '*[^0-9.]*' AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1 AND unit_price <> '.' AND (instr(unit_price, '.') = 0 OR (instr(unit_price, '.') > 1 AND length(unit_price) > instr(unit_price, '.') AND length(unit_price) - instr(unit_price, '.') <= 2))),
|
|
||||||
total_price TEXT NOT NULL CHECK (total_price <> '' AND total_price NOT GLOB '*[^0-9.]*' AND length(total_price) - length(replace(total_price, '.', '')) <= 1 AND total_price <> '.' AND (instr(total_price, '.') = 0 OR (instr(total_price, '.') > 1 AND length(total_price) > instr(total_price, '.') AND length(total_price) - instr(total_price, '.') <= 2))),
|
|
||||||
evidence_sha256 TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
UNIQUE (task_id, attempt),
|
|
||||||
UNIQUE (task_id, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE order_authorizations (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
||||||
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
|
|
||||||
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
|
|
||||||
goods_id TEXT NOT NULL,
|
|
||||||
sku_color TEXT NOT NULL,
|
|
||||||
sku_size TEXT NOT NULL,
|
|
||||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
|
||||||
authorized_unit_price TEXT NOT NULL CHECK (authorized_unit_price <> '' AND authorized_unit_price NOT GLOB '*[^0-9.]*' AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1 AND authorized_unit_price <> '.' AND (instr(authorized_unit_price, '.') = 0 OR (instr(authorized_unit_price, '.') > 1 AND length(authorized_unit_price) > instr(authorized_unit_price, '.') AND length(authorized_unit_price) - instr(authorized_unit_price, '.') <= 2))),
|
|
||||||
total_price_cap TEXT NOT NULL CHECK (total_price_cap <> '' AND total_price_cap NOT GLOB '*[^0-9.]*' AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1 AND total_price_cap <> '.' AND (instr(total_price_cap, '.') = 0 OR (instr(total_price_cap, '.') > 1 AND length(total_price_cap) > instr(total_price_cap, '.') AND length(total_price_cap) - instr(total_price_cap, '.') <= 2))),
|
|
||||||
note TEXT,
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED', 'CONSUMED', 'SUPERSEDED', 'EXPIRED')),
|
|
||||||
created_by TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
UNIQUE (task_id, version),
|
|
||||||
UNIQUE (task_id, id),
|
|
||||||
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE order_submissions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
|
||||||
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
|
|
||||||
command_id TEXT NOT NULL,
|
|
||||||
dry_run_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
|
|
||||||
verified_unit_price TEXT NOT NULL CHECK (verified_unit_price <> '' AND verified_unit_price NOT GLOB '*[^0-9.]*' AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1 AND verified_unit_price <> '.' AND (instr(verified_unit_price, '.') = 0 OR (instr(verified_unit_price, '.') > 1 AND length(verified_unit_price) > instr(verified_unit_price, '.') AND length(verified_unit_price) - instr(verified_unit_price, '.') <= 2))),
|
|
||||||
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
|
|
||||||
confirm_page_amount TEXT NOT NULL CHECK (confirm_page_amount <> '' AND confirm_page_amount NOT GLOB '*[^0-9.]*' AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1 AND confirm_page_amount <> '.' AND (instr(confirm_page_amount, '.') = 0 OR (instr(confirm_page_amount, '.') > 1 AND length(confirm_page_amount) > instr(confirm_page_amount, '.') AND length(confirm_page_amount) - instr(confirm_page_amount, '.') <= 2))),
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
resolved_at TEXT,
|
|
||||||
UNIQUE (authorization_id),
|
|
||||||
UNIQUE (command_id),
|
|
||||||
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
|
|
||||||
);
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202) | T-111 单趟契约已完成;T-103 已恢复;T-202 在独立工作树待主审提交 |
|
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202、T-209) | T-111 单趟契约与 T-202 手工 DRAFT 建单已完成;T-103、T-209 并行推进 |
|
||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||||
@@ -65,11 +65,13 @@
|
|||||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||||
链接打开与目标商品/隐私人工验收)。
|
链接打开与目标商品/隐私人工验收)。
|
||||||
- 已完成 T-010(安全并行门禁)与 T-201(管理员登录与会话)。T-202 已由 admin agent 实现且
|
- 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
|
||||||
测试通过,仍只创建/展示 `DRAFT`;当前在独立工作树等待主 agent 审阅、提交和推送。
|
基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
|
||||||
|
`DRAFT`,未实现授权、设备领取或采购执行。
|
||||||
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
|
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
|
||||||
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
|
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
|
||||||
内部原始截图上传交给 T-204。admin 方向在 T-202 主审合入后,先落 T-209 迁移旧 schema/状态机。
|
内部原始截图上传交给 T-204。admin 方向已启动 T-209,先迁移旧 schema/状态机,再实现 T-203
|
||||||
|
“开始采购”授权事务。
|
||||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||||
可读字段时必须先回修原型与交互清单。
|
可读字段时必须先回修原型与交互清单。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
id: T-203
|
||||||
|
title: 表格查询与批量开始采购授权
|
||||||
|
phase: 2
|
||||||
|
deps: [T-202, T-209]
|
||||||
|
status: TODO
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 30
|
||||||
|
context_ref: b5f45b8
|
||||||
|
work_branch: task/t-203-start-purchases
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-203.md
|
||||||
|
- admin/internal/tasks/**
|
||||||
|
- admin/internal/server/**
|
||||||
|
- admin/internal/transport/webui/**
|
||||||
|
- admin/internal/config/**
|
||||||
|
- admin/cmd/server/**
|
||||||
|
- admin/README.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T08:58:34Z sha256=bf954b6f5e557ca4cf0e50e77c27a4cd10b71f4b92036bdd02ea236a17c6156a -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-202 已完成手工 DRAFT 建单;T-209 将生产 schema/领域状态机迁移到单趟模型。项目所有者明确:管理员点击“开始采购(只创建待付款订单)”本身就是授权,不再增加试选后确认。T-203 负责采购服务查询和批量授权事务,使设备后续只能领取显式授权的 PENDING 任务。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
F-004、F-008、F-018;US-003、US-005;IX-005;GET /tasks、POST /tasks/start-purchases;依赖 T-202、T-209。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. GET /tasks 支持 keyword、status、created_from、created_to;日期按 Asia/Shanghai 转为 UTC 半开区间,非法筛选返回可访问字段错误;默认全部状态并按 created_at DESC,rowid DESC。
|
||||||
|
2. 页面只让 DRAFT 行可勾选;表格上方显示选中数量、最高总额字符串合计、“系统不会付款”和唯一主按钮“开始采购(只创建待付款订单)”,不加逐行操作或重复确认弹窗。JS 只用同源静态文件,金额以分/BigInt 累计,不用浮点。
|
||||||
|
3. POST 使用服务端生成并渲染的 UUID v4 start_key,接收非空去重任务 id + expected_task_version;批量上限 100。created_by 只取已认证管理员,不接受请求字段。
|
||||||
|
4. 在一个有界 SQLite 写事务内先按 start_key 检查重放,再按 task_id 稳定排序读取并复核全部任务:存在、DRAFT、版本相等、锁定字段完整、数量/总额上限满足显式配置。任一失败整批不变。
|
||||||
|
5. 新请求为整批使用同一 created_at/expires_at;逐条创建 ACTIVE 一次性授权,锁定新 task_version、goods_id、颜色、尺码、数量和 total_price_cap;条件更新每条 DRAFT/version 为 PENDING/version+1,任一 RowsAffected != 1 则全批回滚。
|
||||||
|
6. 相同 start_key + 相同规范集合(输入顺序无关)返回原 authorization ids/版本/有效期,不再次改任务;同 key 子集、超集、不同版本或残缺集合返回 409。网络结果不明时前端冻结原 key/载荷,只允许原样重放。
|
||||||
|
7. 显式配置并启动时校验授权 TTL、最大任务数量、最大总额;金额只用规范十进制字符串。T-207 才关闭过期授权,T-203 只写 expires_at。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 覆盖单条/100条成功,任务版本仅加一次、快照逐字段相等、管理员来自服务端、UTC 过期时间正确。
|
||||||
|
- 任一缺失/非 DRAFT/版本冲突/字段不完整/数量或金额超限/中途 SQL 失败均整批零修改。
|
||||||
|
- 同 key 同集合、倒序集合、并发重放返回同一结果;同 key 不同集合冲突;不同 key 并发抢同一版本仅一个成功。
|
||||||
|
- 未登录、CSRF、空/重复/畸形/超大请求、配置边界和数据库故障 fail closed,不泄露内部错误。
|
||||||
|
- UI 只有 DRAFT 可选;筛选、全选当前可见项、选择反馈、网络不明重放、焦点/aria-live/横向滚动与不付款文案有测试;无同义确认弹窗。
|
||||||
|
- go test ./...、go test -race ./...、go vet ./...、go build ./...、node --check 静态 JS、完整 init.ps1、上下文校验和 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
(暂无)
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只实现管理员任务查询与批量“开始采购”授权;不实现任务详情/截图、设备 Bearer 身份、领取/
|
||||||
|
租约、purchase attempt API、提交围栏、结果调和、真机自动化、创建订单点击或付款。
|
||||||
|
- “开始采购(只创建待付款订单)”按钮本身就是明确授权,不得再增加同义确认弹窗,也不得把它拆回
|
||||||
|
试选后确认。按钮附近必须持续显示系统不付款;页面不得提供逐行“开始采购”操作列。
|
||||||
|
- 只有 `DRAFT` 行可以勾选;批量事务必须全有或全无。任一任务缺失、状态/版本变化、锁定字段非法、
|
||||||
|
配置超限、授权插入失败或条件更新未命中,都不得留下部分授权或部分 `PENDING`。
|
||||||
|
- 幂等重放必须先于 DRAFT 状态检查:相同 `start_key` 和相同规范任务集合只返回原结果,任务版本不得
|
||||||
|
再增加;同 key 的子集、超集、不同 expected version 或残缺授权集合一律冲突。网络结果不明只能
|
||||||
|
原样重放同一个 key 和载荷,不能生成新 key。
|
||||||
|
- 授权只锁定任务的新版本、goods_id、颜色、尺码、数量和 `total_price_cap`;不得写入观察单价或
|
||||||
|
`authorized_unit_price`。金额校验、配置比较和浏览器合计均使用十进制字符串/整数分,不用浮点。
|
||||||
|
- `created_by` 只能来自已认证管理员会话;POST 必须验证 CSRF。设备凭据不能调用本接口,本任务也不
|
||||||
|
新增设备接口。内部数据库错误不得回显 SQL、路径、配置值或凭据。
|
||||||
|
- 过期授权的关闭/重置属于 T-207;本任务只创建 `expires_at`。授权一旦进入 `FENCED`,本任务没有
|
||||||
|
释放、取消、重新授权或重试入口。
|
||||||
|
- 本任务不实现、不引用通用真机点击、`submit_order_once()` 或任何支付、免密支付、先用后付能力。
|
||||||
+1
-1
@@ -3,7 +3,7 @@ id: T-209
|
|||||||
title: 把核心 schema / 状态机迁移为单趟模型
|
title: 把核心 schema / 状态机迁移为单趟模型
|
||||||
phase: 2
|
phase: 2
|
||||||
deps: [T-004, T-111]
|
deps: [T-004, T-111]
|
||||||
status: DONE
|
status: DOING
|
||||||
created: 2026-08-04
|
created: 2026-08-04
|
||||||
vikunja_task_id: 29
|
vikunja_task_id: 29
|
||||||
context_ref: da540bf
|
context_ref: da540bf
|
||||||
|
|||||||
Reference in New Issue
Block a user