feat(t222): add freight ingestion admin flow
This commit is contained in:
@@ -267,7 +267,9 @@ func safeNext(value string) string {
|
||||
return "/tasks"
|
||||
}
|
||||
if parsed.Path != "/tasks" &&
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") {
|
||||
!strings.HasPrefix(parsed.Path, "/tasks/") &&
|
||||
parsed.Path != "/freight" &&
|
||||
!strings.HasPrefix(parsed.Path, "/freight/") {
|
||||
return "/tasks"
|
||||
}
|
||||
return parsed.String()
|
||||
|
||||
@@ -294,6 +294,9 @@ func TestSafeNextRejectsExternalAndAmbiguousPaths(t *testing.T) {
|
||||
if actual := safeNext("/tasks/item?id=1"); actual != "/tasks/item?id=1" {
|
||||
t.Fatalf("safeNext(valid) = %q", actual)
|
||||
}
|
||||
if actual := safeNext("/freight/import"); actual != "/freight/import" {
|
||||
t.Fatalf("safeNext(freight) = %q", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
|
||||
@@ -71,6 +71,136 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
||||
SecurityHeaders(),
|
||||
h.AuthorizeOrder,
|
||||
)
|
||||
if _, ok := h.service.(FreightService); ok {
|
||||
routes.GET("/freight", SecurityHeaders(), h.ListFreight)
|
||||
routes.GET("/freight/import", SecurityHeaders(), h.ImportFreight)
|
||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListFreight(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
orders, err := service.ListFreightOrders(
|
||||
ctx.Request.Context(),
|
||||
defaultListLimit,
|
||||
)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载货运列表,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "freight", freightPage{
|
||||
Page: pageView{
|
||||
Title: "ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Orders: orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ImportFreight(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
token, err := csrfToken(ctx)
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
key, err := newToken()
|
||||
if err != nil {
|
||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||
return
|
||||
}
|
||||
page := freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
if syncID := strings.TrimSpace(ctx.Query("sync")); syncID != "" {
|
||||
run, getErr := service.GetFreightSync(ctx.Request.Context(), syncID)
|
||||
if getErr == nil {
|
||||
page.Sync = &run
|
||||
}
|
||||
}
|
||||
h.render(ctx, http.StatusOK, "freight-import", page)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFreightImport(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
|
||||
}
|
||||
orderNumber := strings.TrimSpace(ctx.PostForm("order_number"))
|
||||
key := strings.TrimSpace(ctx.PostForm("idempotency_key"))
|
||||
if orderNumber == "" || len([]byte(orderNumber)) > 128 ||
|
||||
!validToken(key) {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, http.StatusUnprocessableEntity, "freight-import", freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
IdempotencyKey: key,
|
||||
Error: "请输入完整单号后重试。",
|
||||
})
|
||||
return
|
||||
}
|
||||
service := h.service.(FreightService)
|
||||
run, err := service.CreateFreightSync(
|
||||
ctx.Request.Context(),
|
||||
CreateFreightSyncInput{
|
||||
ActorUserID: actorUserID(ctx.Request.Context()),
|
||||
IdempotencyKey: key,
|
||||
OrderNumber: orderNumber,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
|
||||
Page: pageView{
|
||||
Title: "导入 ERP 货运",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
OrderNumber: orderNumber,
|
||||
IdempotencyKey: key,
|
||||
Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
|
||||
}
|
||||
|
||||
func (h *Handler) FreightDetail(ctx *gin.Context) {
|
||||
service := h.service.(FreightService)
|
||||
detail, err := service.GetFreightOrder(
|
||||
ctx.Request.Context(),
|
||||
strings.TrimSpace(ctx.Param("id")),
|
||||
)
|
||||
if err != nil {
|
||||
h.renderServiceError(ctx, err, "无法加载货运详情,请稍后重试。")
|
||||
return
|
||||
}
|
||||
token, _ := csrfToken(ctx)
|
||||
h.render(ctx, http.StatusOK, "freight-detail", freightDetailPage{
|
||||
Page: pageView{
|
||||
Title: "货运详情",
|
||||
FreightCurrent: true,
|
||||
CSRFToken: token,
|
||||
},
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
@@ -739,10 +869,29 @@ func fallback(value string, fallbackValue string) string {
|
||||
}
|
||||
|
||||
type pageView struct {
|
||||
Title string
|
||||
TasksCurrent bool
|
||||
NewCurrent bool
|
||||
CSRFToken string
|
||||
Title string
|
||||
TasksCurrent bool
|
||||
NewCurrent bool
|
||||
FreightCurrent bool
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
type freightPage struct {
|
||||
Page pageView
|
||||
Orders []FreightOrder
|
||||
}
|
||||
|
||||
type freightImportPage struct {
|
||||
Page pageView
|
||||
OrderNumber string
|
||||
IdempotencyKey string
|
||||
Error string
|
||||
Sync *FreightSync
|
||||
}
|
||||
|
||||
type freightDetailPage struct {
|
||||
Page pageView
|
||||
Detail FreightOrderDetail
|
||||
}
|
||||
|
||||
type statusOption struct {
|
||||
|
||||
@@ -922,6 +922,78 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
|
||||
service := &fakeFreightService{
|
||||
fakeService: &fakeService{},
|
||||
orders: []FreightOrder{{
|
||||
ID: testTaskID,
|
||||
ExternalStockID: "12",
|
||||
SourceCode: `<script>private</script>`,
|
||||
ShopName: "测试店铺",
|
||||
ItemCount: 2,
|
||||
Revision: 1,
|
||||
UpdatedAt: now,
|
||||
}},
|
||||
createResult: FreightSync{
|
||||
ID: testTaskID,
|
||||
Status: "PENDING",
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
list := performRequest(t, router, http.MethodGet, "/freight", nil, "")
|
||||
if list.Code != http.StatusOK ||
|
||||
strings.Contains(list.Body.String(), `<script>private</script>`) ||
|
||||
!strings.Contains(list.Body.String(), "<script>private") ||
|
||||
!strings.Contains(list.Body.String(), "2 项") {
|
||||
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
|
||||
}
|
||||
assertSecurityHeaders(t, list)
|
||||
|
||||
form := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/freight/import",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
cookie := csrfCookie(t, form)
|
||||
idempotencyKey := hiddenValue(
|
||||
t,
|
||||
form.Body.String(),
|
||||
"idempotency_key",
|
||||
)
|
||||
values := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"idempotency_key": {idempotencyKey},
|
||||
"order_number": {"SOURCE-12"},
|
||||
}
|
||||
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 ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/import?sync="+testTaskID {
|
||||
t.Fatalf(
|
||||
"create sync status/location = %d / %q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
if service.createInput.OrderNumber != "SOURCE-12" ||
|
||||
service.createInput.IdempotencyKey != idempotencyKey {
|
||||
t.Fatalf("create input = %+v", service.createInput)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct {
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
@@ -945,6 +1017,45 @@ type fakeService struct {
|
||||
authorizeInput AuthorizeOrderInput
|
||||
}
|
||||
|
||||
type fakeFreightService struct {
|
||||
*fakeService
|
||||
orders []FreightOrder
|
||||
orderDetail FreightOrderDetail
|
||||
sync FreightSync
|
||||
createInput CreateFreightSyncInput
|
||||
createResult FreightSync
|
||||
err error
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) ListFreightOrders(
|
||||
context.Context,
|
||||
int,
|
||||
) ([]FreightOrder, error) {
|
||||
return service.orders, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightOrder(
|
||||
context.Context,
|
||||
string,
|
||||
) (FreightOrderDetail, error) {
|
||||
return service.orderDetail, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
) (FreightSync, error) {
|
||||
return service.sync, service.err
|
||||
}
|
||||
|
||||
func (service *fakeFreightService) CreateFreightSync(
|
||||
_ context.Context,
|
||||
input CreateFreightSyncInput,
|
||||
) (FreightSync, error) {
|
||||
service.createInput = input
|
||||
return service.createResult, service.err
|
||||
}
|
||||
|
||||
func (service *fakeService) ListTasks(
|
||||
_ context.Context,
|
||||
input ListTasksInput,
|
||||
|
||||
@@ -673,6 +673,54 @@ tbody tr:last-child td {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.notice.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger-dark);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.detail-grid > div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.detail-grid dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-grid dd {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -1139,6 +1187,10 @@ tbody tr:last-child td {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.authorization-note {
|
||||
grid-column: auto;
|
||||
}
|
||||
@@ -1154,11 +1206,7 @@ tbody tr:last-child td {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
.brand span:not(.brand-mark) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1167,13 +1215,13 @@ tbody tr:last-child td {
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
padding-inline: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
@@ -1186,6 +1234,10 @@ tbody tr:last-child td {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{{define "freight-detail"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<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>
|
||||
<section class="detail-section" aria-labelledby="freight-source-title">
|
||||
<h2 id="freight-source-title">来源信息</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>店铺</dt><dd>{{if .Detail.Order.ShopName}}{{.Detail.Order.ShopName}}{{else}}未提供{{end}}</dd></div>
|
||||
<div><dt>ERP 创建时间</dt><dd>{{displayTime .Detail.Order.SourceCreatedAt}}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{if .Detail.Order.OrderStatus}}{{.Detail.Order.OrderStatus}}{{else}}未提供{{end}}</dd></div>
|
||||
<div><dt>采购状态</dt><dd>{{if .Detail.Order.PurchaseStatus}}{{.Detail.Order.PurchaseStatus}}{{else}}未提供{{end}}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="table-region" aria-labelledby="freight-items-title">
|
||||
<h2 id="freight-items-title">商品明细</h2>
|
||||
{{if .Detail.Items}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">商品</th>
|
||||
<th scope="col">规格 / SKU</th>
|
||||
<th scope="col">数量</th>
|
||||
<th scope="col">采购状态</th>
|
||||
<th scope="col">来源版本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Detail.Items}}
|
||||
<tr>
|
||||
<td data-label="商品">
|
||||
<strong>{{if .Title}}{{.Title}}{{else}}缺少标题{{end}}</strong>
|
||||
<span class="secondary">明细 ID:{{.ExternalItemID}}</span>
|
||||
{{if .ProductThumbRef}}<span class="secondary">图片引用:{{.ProductThumbRef}}</span>{{end}}
|
||||
</td>
|
||||
<td data-label="规格 / SKU">
|
||||
<span>{{if .ProductSpec}}{{.ProductSpec}}{{else}}未提供规格{{end}}</span>
|
||||
<span class="secondary">SKU:{{if .SKU}}{{.SKU}}{{else}}未提供{{end}}</span>
|
||||
</td>
|
||||
<td data-label="数量">{{if .Quantity}}{{.Quantity}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="采购状态">{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="来源版本">{{.Revision}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state"><h2>该货运单没有商品明细</h2></div>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "freight-import"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page narrow-page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>导入 ERP 货运</h1>
|
||||
<p class="subtitle">使用 ERP 页面“全部单号”中的完整单号</p>
|
||||
</div>
|
||||
<a class="button" href="/freight">返回列表</a>
|
||||
</div>
|
||||
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
|
||||
{{if .Sync}}
|
||||
<section class="detail-section" aria-labelledby="sync-result-title">
|
||||
<h2 id="sync-result-title">同步状态</h2>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>状态</dt><dd>{{.Sync.Status}}</dd></div>
|
||||
<div><dt>货运单</dt><dd>{{.Sync.OrderCount}}</dd></div>
|
||||
<div><dt>商品明细</dt><dd>{{.Sync.ItemCount}}</dd></div>
|
||||
<div><dt>错误码</dt><dd>{{if .Sync.ErrorCode}}{{.Sync.ErrorCode}}{{else}}无{{end}}</dd></div>
|
||||
</dl>
|
||||
{{if or (eq .Sync.Status "PENDING") (eq .Sync.Status "RUNNING")}}
|
||||
<p class="secondary">同步仍在后台执行,刷新本页查看结果。</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{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}}">
|
||||
<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>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,62 @@
|
||||
{{define "freight"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>ERP 货运</h1>
|
||||
<p class="subtitle">核对已同步的货运单和全部商品明细</p>
|
||||
</div>
|
||||
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||
</div>
|
||||
<section class="table-region" aria-labelledby="freight-table-title">
|
||||
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
|
||||
{{if .Orders}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">来源单号</th>
|
||||
<th scope="col">店铺</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">商品</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col"><span class="visually-hidden">操作</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Orders}}
|
||||
<tr>
|
||||
<td data-label="来源单号">
|
||||
<strong>{{if .SourceCode}}{{.SourceCode}}{{else}}{{.ExternalStockID}}{{end}}</strong>
|
||||
<span class="secondary">ERP ID:{{.ExternalStockID}}</span>
|
||||
</td>
|
||||
<td data-label="店铺">{{if .ShopName}}{{.ShopName}}{{else}}未提供{{end}}</td>
|
||||
<td data-label="状态">
|
||||
<span class="secondary">订单:{{if .OrderStatus}}{{.OrderStatus}}{{else}}未提供{{end}}</span>
|
||||
<span class="secondary">采购:{{if .PurchaseStatus}}{{.PurchaseStatus}}{{else}}未提供{{end}}</span>
|
||||
</td>
|
||||
<td data-label="商品">{{.ItemCount}} 项</td>
|
||||
<td data-label="更新时间"><time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time></td>
|
||||
<td data-label="操作"><a class="detail-link" href="/freight/{{pathPart .ID}}">查看详情</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<h2>尚未导入货运单</h2>
|
||||
<p>按完整单号创建第一条 ERP 同步任务。</p>
|
||||
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -17,6 +17,7 @@
|
||||
<nav class="main-nav" aria-label="主导航">
|
||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
|
||||
</nav>
|
||||
{{if .Page.CSRFToken}}
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
|
||||
@@ -27,6 +27,63 @@ type Service interface {
|
||||
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
|
||||
}
|
||||
|
||||
type FreightService interface {
|
||||
ListFreightOrders(context.Context, int) ([]FreightOrder, error)
|
||||
GetFreightOrder(context.Context, string) (FreightOrderDetail, error)
|
||||
GetFreightSync(context.Context, string) (FreightSync, error)
|
||||
CreateFreightSync(
|
||||
context.Context,
|
||||
CreateFreightSyncInput,
|
||||
) (FreightSync, error)
|
||||
}
|
||||
|
||||
type FreightSync struct {
|
||||
ID string
|
||||
Status string
|
||||
ErrorCode string
|
||||
OrderCount int
|
||||
ItemCount int
|
||||
CreatedAt time.Time
|
||||
FinishedAt time.Time
|
||||
}
|
||||
|
||||
type CreateFreightSyncInput struct {
|
||||
ActorUserID string
|
||||
IdempotencyKey string
|
||||
OrderNumber string
|
||||
}
|
||||
|
||||
type FreightOrder struct {
|
||||
ID string
|
||||
ExternalStockID string
|
||||
SourceCode string
|
||||
ShopName string
|
||||
SourceCreatedAt time.Time
|
||||
OrderStatus string
|
||||
PurchaseStatus string
|
||||
IsCanceled bool
|
||||
ItemCount int
|
||||
Revision int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type FreightOrderItem struct {
|
||||
ID string
|
||||
ExternalItemID string
|
||||
Title string
|
||||
ProductSpec string
|
||||
SKU string
|
||||
Quantity int
|
||||
ProductThumbRef string
|
||||
PurchaseStatus string
|
||||
Revision int
|
||||
}
|
||||
|
||||
type FreightOrderDetail struct {
|
||||
Order FreightOrder
|
||||
Items []FreightOrderItem
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
Query string
|
||||
Status string
|
||||
|
||||
@@ -17,23 +17,157 @@ type UsecaseAdapter struct {
|
||||
tasks *usecase.TaskService
|
||||
assets *usecase.AssetService
|
||||
authorizations *usecase.OrderAuthorizationService
|
||||
freight *usecase.FreightService
|
||||
}
|
||||
|
||||
func NewUsecaseAdapter(
|
||||
tasks *usecase.TaskService,
|
||||
assets *usecase.AssetService,
|
||||
authorizations *usecase.OrderAuthorizationService,
|
||||
freight ...*usecase.FreightService,
|
||||
) (*UsecaseAdapter, error) {
|
||||
if tasks == nil || assets == nil || authorizations == nil {
|
||||
return nil, errors.New("admin web use cases are required")
|
||||
}
|
||||
return &UsecaseAdapter{
|
||||
adapter := &UsecaseAdapter{
|
||||
tasks: tasks,
|
||||
assets: assets,
|
||||
authorizations: authorizations,
|
||||
}
|
||||
if len(freight) > 0 {
|
||||
adapter.freight = freight[0]
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListFreightOrders(
|
||||
ctx context.Context,
|
||||
limit int,
|
||||
) ([]FreightOrder, error) {
|
||||
if adapter.freight == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
orders, err := adapter.freight.ListOrders(ctx, localAdminSubject, limit)
|
||||
if err != nil {
|
||||
return nil, mapUsecaseError(err)
|
||||
}
|
||||
result := make([]FreightOrder, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
result = append(result, freightOrderFrom(order))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightOrder(
|
||||
ctx context.Context,
|
||||
orderID string,
|
||||
) (FreightOrderDetail, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightOrderDetail{}, ErrUnavailable
|
||||
}
|
||||
detail, err := adapter.freight.GetOrder(
|
||||
ctx,
|
||||
localAdminSubject,
|
||||
orderID,
|
||||
)
|
||||
if err != nil {
|
||||
return FreightOrderDetail{}, mapUsecaseError(err)
|
||||
}
|
||||
items := make([]FreightOrderItem, 0, len(detail.Items))
|
||||
for _, item := range detail.Items {
|
||||
view := FreightOrderItem{
|
||||
ID: item.ID,
|
||||
ExternalItemID: item.ExternalItemID,
|
||||
Title: item.Title,
|
||||
ProductSpec: item.ProductSpec,
|
||||
SKU: item.SKU,
|
||||
ProductThumbRef: stringValue(item.ProductThumbRef),
|
||||
PurchaseStatus: stringValue(item.PurchaseStatus),
|
||||
Revision: item.Revision,
|
||||
}
|
||||
if item.Quantity != nil {
|
||||
view.Quantity = *item.Quantity
|
||||
}
|
||||
items = append(items, view)
|
||||
}
|
||||
return FreightOrderDetail{
|
||||
Order: freightOrderFrom(detail.Order),
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetFreightSync(
|
||||
ctx context.Context,
|
||||
syncID string,
|
||||
) (FreightSync, error) {
|
||||
if adapter.freight == nil {
|
||||
return FreightSync{}, ErrUnavailable
|
||||
}
|
||||
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
|
||||
if err != nil {
|
||||
return FreightSync{}, mapUsecaseError(err)
|
||||
}
|
||||
return freightSyncFrom(run), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateFreightSync(
|
||||
ctx context.Context,
|
||||
input CreateFreightSyncInput,
|
||||
) (FreightSync, error) {
|
||||
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,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return FreightSync{}, mapUsecaseError(err)
|
||||
}
|
||||
return freightSyncFrom(result.Run), nil
|
||||
}
|
||||
|
||||
func freightOrderFrom(order domain.FreightOrder) FreightOrder {
|
||||
result := FreightOrder{
|
||||
ID: order.ID,
|
||||
ExternalStockID: order.ExternalStockID,
|
||||
SourceCode: order.SourceCode,
|
||||
ShopName: stringValue(order.ShopName),
|
||||
OrderStatus: stringValue(order.OrderStatus),
|
||||
PurchaseStatus: stringValue(order.PurchaseStatus),
|
||||
ItemCount: order.ItemCount,
|
||||
Revision: order.Revision,
|
||||
UpdatedAt: order.UpdatedAt,
|
||||
}
|
||||
if order.SourceCreatedAt != nil {
|
||||
result.SourceCreatedAt = *order.SourceCreatedAt
|
||||
}
|
||||
if order.IsCanceled != nil {
|
||||
result.IsCanceled = *order.IsCanceled
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
if run.FinishedAt != nil {
|
||||
result.FinishedAt = *run.FinishedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListTasks(
|
||||
ctx context.Context,
|
||||
input ListTasksInput,
|
||||
@@ -525,3 +659,4 @@ func (err *adapterError) Unwrap() []error {
|
||||
}
|
||||
|
||||
var _ Service = (*UsecaseAdapter)(nil)
|
||||
var _ FreightService = (*UsecaseAdapter)(nil)
|
||||
|
||||
Reference in New Issue
Block a user