feat(t224): add incremental freight sync

This commit is contained in:
QiuSW
2026-07-29 00:26:05 +08:00
parent 433db45100
commit c69879650e
35 changed files with 2085 additions and 156 deletions
@@ -140,6 +140,17 @@ func (h *Handler) ImportFreight(ctx *gin.Context) {
CSRFToken: token,
},
IdempotencyKey: key,
Mode: "ORDER_NUMBER",
}
if location, locationErr := time.LoadLocation("Asia/Shanghai"); locationErr == nil {
today := time.Now().In(location).Format(time.DateOnly)
page.CreatedFrom = today
page.CreatedTo = today
}
if watermark, watermarkErr := service.GetFreightWatermark(
ctx.Request.Context(),
); watermarkErr == nil {
page.Watermark = watermark
}
if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" {
run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID)
@@ -157,9 +168,29 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
return
}
orderNumber := strings.TrimSpace(ctx.PostForm("order_number"))
mode := strings.TrimSpace(ctx.PostForm("mode"))
if mode == "" {
mode = "ORDER_NUMBER"
}
createdFrom := strings.TrimSpace(ctx.PostForm("created_from"))
createdTo := strings.TrimSpace(ctx.PostForm("created_to"))
syncToNow := ctx.PostForm("sync_to_now") == "true"
if syncToNow {
createdFrom = ""
createdTo = ""
}
key := strings.TrimSpace(ctx.PostForm("idempotency_key"))
if orderNumber == "" || len([]byte(orderNumber)) > 128 ||
!validToken(key) {
validInput := validToken(key)
if mode == "ORDER_NUMBER" {
validInput = validInput && orderNumber != "" &&
len([]byte(orderNumber)) <= 128
} else if mode == "CREATED_RANGE" {
validInput = validInput &&
(syncToNow || (createdFrom != "" && createdTo != ""))
} else {
validInput = false
}
if !validInput {
token, _ := csrfToken(ctx)
h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{
Page: pageView{
@@ -168,8 +199,11 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
CSRFToken: token,
},
OrderNumber: orderNumber,
Mode: mode,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
IdempotencyKey: key,
Error: "请输入完整单号后重试。",
Error: "请检查同步方式和查询条件后重试。",
})
return
}
@@ -179,7 +213,11 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
CreateFreightSyncInput{
ActorUserID: actorUserID(ctx.Request.Context()),
IdempotencyKey: key,
Mode: mode,
OrderNumber: orderNumber,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
SyncToNow: syncToNow,
},
)
if err != nil {
@@ -191,6 +229,9 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
CSRFToken: token,
},
OrderNumber: orderNumber,
Mode: mode,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
IdempotencyKey: key,
Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
})
@@ -1056,10 +1097,14 @@ type freightPage struct {
type freightImportPage struct {
Page pageView
Mode string
OrderNumber string
CreatedFrom string
CreatedTo string
IdempotencyKey string
Error string
Sync *FreightSync
Watermark *FreightWatermark
}
type freightDetailPage struct {
@@ -959,6 +959,11 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
nil,
"",
)
if !strings.Contains(form.Body.String(), "同步至现在") ||
!strings.Contains(form.Body.String(), `name="created_from"`) ||
!strings.Contains(form.Body.String(), "尚无日期同步水位") {
t.Fatalf("freight import form = %s", form.Body)
}
cookie := csrfCookie(t, form)
idempotencyKey := hiddenValue(
t,
@@ -994,6 +999,102 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
}
}
func TestFreightDateFormSubmitsManualRange(t *testing.T) {
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
service := &fakeFreightService{
fakeService: &fakeService{},
watermark: &FreightWatermark{
LastSuccessfulTo: now,
LastSuccessfulRunID: testTaskID,
UpdatedAt: now,
},
createResult: FreightSync{
ID: testTaskID,
Mode: "CREATED_RANGE",
Status: "PENDING",
CreatedAt: now,
},
}
router := newTestRouter(t, service)
form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
if !strings.Contains(form.Body.String(), "增量同步水位") ||
!strings.Contains(form.Body.String(), testTaskID) {
t.Fatalf("watermark form = %s", form.Body)
}
cookie := csrfCookie(t, form)
key := hiddenValue(t, form.Body.String(), "idempotency_key")
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {key},
"mode": {"CREATED_RANGE"},
"created_from": {"2026-07-22"},
"created_to": {"2026-07-28"},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/import",
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 ||
service.createInput.Mode != "CREATED_RANGE" ||
service.createInput.CreatedFrom != "2026-07-22" ||
service.createInput.CreatedTo != "2026-07-28" {
t.Fatalf(
"date form response/input = %d / %+v",
response.Code,
service.createInput,
)
}
}
func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) {
service := &fakeFreightService{
fakeService: &fakeService{},
createResult: FreightSync{
ID: testTaskID,
Mode: "CREATED_RANGE",
Status: "PENDING",
},
}
router := newTestRouter(t, service)
form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
cookie := csrfCookie(t, form)
key := hiddenValue(t, form.Body.String(), "idempotency_key")
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {key},
"mode": {"CREATED_RANGE"},
"created_from": {"2026-07-22"},
"created_to": {"2026-07-28"},
"sync_to_now": {"true"},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/import",
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 ||
!service.createInput.SyncToNow ||
service.createInput.CreatedFrom != "" ||
service.createInput.CreatedTo != "" {
t.Fatalf(
"sync-to-now response/input = %d / %+v",
response.Code,
service.createInput,
)
}
}
func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
const itemID = "00000000-0000-4000-8000-000000000002"
service := &fakeProcurementService{
@@ -1101,6 +1202,7 @@ type fakeFreightService struct {
sync FreightSync
createInput CreateFreightSyncInput
createResult FreightSync
watermark *FreightWatermark
err error
}
@@ -1159,6 +1261,12 @@ func (service *fakeFreightService) GetFreightSync(
return service.sync, service.err
}
func (service *fakeFreightService) GetFreightWatermark(
context.Context,
) (*FreightWatermark, error) {
return service.watermark, service.err
}
func (service *fakeFreightService) CreateFreightSync(
_ context.Context,
input CreateFreightSyncInput,
@@ -688,6 +688,27 @@ tbody tr:last-child td {
background: var(--surface);
}
.form-panel + .form-panel {
margin-top: 18px;
}
.form-panel h2 {
margin: 0;
font-size: 18px;
}
.date-range-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.detail-section {
margin-bottom: 22px;
}
@@ -1273,7 +1294,8 @@ tbody tr:last-child td {
}
.filters,
.form-grid {
.form-grid,
.date-range-fields {
grid-template-columns: 1fr;
}
@@ -11,7 +11,7 @@
<div class="title-row">
<div>
<h1>导入 ERP 货运</h1>
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
<p class="subtitle">按完整单号导入,或按创建日期发现新增货运</p>
</div>
<a class="button" href="/freight">返回列表</a>
</div>
@@ -20,6 +20,10 @@
<section class="detail-section" aria-labelledby="sync-result-title">
<h2 id="sync-result-title">同步状态</h2>
<dl class="detail-grid">
<div><dt>方式</dt><dd>{{if eq .Sync.Mode "CREATED_RANGE"}}创建日期{{else}}完整单号{{end}}</dd></div>
{{if eq .Sync.Mode "CREATED_RANGE"}}
<div><dt>查询范围</dt><dd>{{.Sync.CreatedFrom}} 至 {{.Sync.CreatedTo}}</dd></div>
{{end}}
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
@@ -30,15 +34,49 @@
{{end}}
</section>
{{end}}
{{if .Watermark}}
<section class="detail-section" aria-labelledby="watermark-title">
<h2 id="watermark-title">增量同步水位</h2>
<dl class="detail-grid">
<div><dt>已成功同步至</dt><dd><time datetime="{{machineTime .Watermark.LastSuccessfulTo}}">{{displayTime .Watermark.LastSuccessfulTo}}</time></dd></div>
<div><dt>最近成功任务</dt><dd>{{.Watermark.LastSuccessfulRunID}}</dd></div>
</dl>
</section>
{{else}}
<div class="notice" role="status">尚无日期同步水位,“同步至现在”将从今天开始。</div>
{{end}}
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
<input type="hidden" name="mode" value="ORDER_NUMBER">
<h2>按完整单号</h2>
<div class="field">
<label for="order-number">完整单号</label>
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
maxlength="128" autocomplete="off" required>
</div>
<button class="button primary" type="submit" data-loading-label="正在创建…">开始同步</button>
<button class="button primary" type="submit" data-loading-label="正在创建…">同步此单号</button>
</form>
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="idempotency_key" value="{{.IdempotencyKey}}">
<input type="hidden" name="mode" value="CREATED_RANGE">
<h2>按创建日期</h2>
<div class="date-range-fields">
<div class="field">
<label for="created-from">开始日期</label>
<input id="created-from" name="created_from" type="date" value="{{.CreatedFrom}}" required>
</div>
<div class="field">
<label for="created-to">结束日期</label>
<input id="created-to" name="created_to" type="date" value="{{.CreatedTo}}" required>
</div>
</div>
<div class="button-row">
<button class="button primary" type="submit" data-loading-label="正在创建…">同步日期范围</button>
<button class="button" type="submit" name="sync_to_now" value="true"
formnovalidate data-loading-label="正在创建…">同步至现在</button>
</div>
</form>
</main>
</body>
+22 -7
View File
@@ -31,6 +31,7 @@ type FreightService interface {
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
GetFreightSync(context.Context, string) (FreightSync, error)
GetFreightWatermark(context.Context) (*FreightWatermark, error)
CreateFreightSync(
context.Context,
CreateFreightSyncInput,
@@ -71,19 +72,33 @@ type CreateProcurementTaskInput struct {
}
type FreightSync struct {
ID string
Status string
ErrorCode string
OrderCount int
ItemCount int
CreatedAt time.Time
FinishedAt time.Time
ID string
Mode string
CreatedFrom string
CreatedTo string
WatermarkThrough time.Time
Status string
ErrorCode string
OrderCount int
ItemCount int
CreatedAt time.Time
FinishedAt time.Time
}
type FreightWatermark struct {
LastSuccessfulTo time.Time
LastSuccessfulRunID string
UpdatedAt time.Time
}
type CreateFreightSyncInput struct {
ActorUserID string
IdempotencyKey string
Mode string
OrderNumber string
CreatedFrom string
CreatedTo string
SyncToNow bool
}
type FreightOrder struct {
@@ -270,21 +270,57 @@ func (adapter *UsecaseAdapter) CreateFreightSync(
if adapter.freight == nil {
return FreightSync{}, ErrUnavailable
}
result, err := adapter.freight.CreateOrderSync(
ctx,
usecase.CreateFreightSyncCommand{
CreatorSubject: localAdminSubject,
ActorUserID: input.ActorUserID,
IdempotencyKey: input.IdempotencyKey,
OrderNumber: input.OrderNumber,
},
)
var result usecase.CreateFreightSyncResult
var err error
if input.Mode == domain.FreightSyncCreatedRange {
result, err = adapter.freight.CreateDateSync(
ctx,
usecase.CreateFreightDateSyncCommand{
CreatorSubject: localAdminSubject,
ActorUserID: input.ActorUserID,
IdempotencyKey: input.IdempotencyKey,
CreatedFrom: input.CreatedFrom,
CreatedTo: input.CreatedTo,
SyncToNow: input.SyncToNow,
},
)
} else {
result, err = adapter.freight.CreateOrderSync(
ctx,
usecase.CreateFreightSyncCommand{
CreatorSubject: localAdminSubject,
ActorUserID: input.ActorUserID,
IdempotencyKey: input.IdempotencyKey,
OrderNumber: input.OrderNumber,
},
)
}
if err != nil {
return FreightSync{}, mapUsecaseError(err)
}
return freightSyncFrom(result.Run), nil
}
func (adapter *UsecaseAdapter) GetFreightWatermark(
ctx context.Context,
) (*FreightWatermark, error) {
if adapter.freight == nil {
return nil, ErrUnavailable
}
watermark, err := adapter.freight.GetWatermark(ctx, localAdminSubject)
if err != nil {
return nil, mapUsecaseError(err)
}
if watermark == nil {
return nil, nil
}
return &FreightWatermark{
LastSuccessfulTo: watermark.LastSuccessfulTo,
LastSuccessfulRunID: watermark.LastSuccessfulRunID,
UpdatedAt: watermark.UpdatedAt,
}, nil
}
func freightOrderFrom(order domain.FreightOrder) FreightOrder {
result := FreightOrder{
ID: order.ID,
@@ -308,12 +344,18 @@ func freightOrderFrom(order domain.FreightOrder) FreightOrder {
func freightSyncFrom(run domain.FreightSyncRun) FreightSync {
result := FreightSync{
ID: run.ID,
Status: string(run.Status),
ErrorCode: stringValue(run.ErrorCode),
OrderCount: run.OrderCount,
ItemCount: run.ItemCount,
CreatedAt: run.CreatedAt,
ID: run.ID,
Mode: run.Mode,
CreatedFrom: run.CreatedFrom,
CreatedTo: run.CreatedTo,
Status: string(run.Status),
ErrorCode: stringValue(run.ErrorCode),
OrderCount: run.OrderCount,
ItemCount: run.ItemCount,
CreatedAt: run.CreatedAt,
}
if run.WatermarkThrough != nil {
result.WatermarkThrough = *run.WatermarkThrough
}
if run.FinishedAt != nil {
result.FinishedAt = *run.FinishedAt