feat: 增加档口入库码独立页面 (#233)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/service"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
var innerCodeLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
// InnerCodeList 渲染独立的档口入库码工作台。
|
||||
func (h *Handler) InnerCodeList(c *gin.Context) {
|
||||
businessDate := strings.TrimSpace(c.Query("date"))
|
||||
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
|
||||
businessDate = time.Now().In(innerCodeLocation).Format("2006-01-02")
|
||||
}
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
keyword := strings.TrimSpace(c.Query("q"))
|
||||
result, err := service.ListInnerCodePage(h.db, businessDate, status, keyword, service.ParsePage(c.Query("page")))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "读取档口入库码列表失败,请稍后刷新重试。")
|
||||
return
|
||||
}
|
||||
query := url.Values{"date": {businessDate}}
|
||||
if result.Status != "" {
|
||||
query.Set("status", result.Status)
|
||||
}
|
||||
if keyword != "" {
|
||||
query.Set("q", keyword)
|
||||
}
|
||||
c.HTML(http.StatusOK, "inner_code/list", page(c, "inner-codes", "档口入库码", gin.H{
|
||||
"BusinessDate": businessDate,
|
||||
"StatusFilter": result.Status,
|
||||
"Keyword": keyword,
|
||||
"StatusOptions": service.InnerCodeStatusOptions(),
|
||||
"Rows": result.Rows,
|
||||
"HasAny": result.Counts.Total > 0,
|
||||
"IsFiltered": result.IsFiltered,
|
||||
"CurrentPage": result.Page,
|
||||
"Message": strings.TrimSpace(c.Query("message")),
|
||||
"Status": service.InnerCodeStatusMessage(result),
|
||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, query.Encode()),
|
||||
}))
|
||||
}
|
||||
|
||||
// InnerCodeImport 接收一次性 xlsx,导入完成后总会删除临时文件。
|
||||
func (h *Handler) InnerCodeImport(c *gin.Context) {
|
||||
businessDate := strings.TrimSpace(c.PostForm("date"))
|
||||
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
|
||||
fail(c, http.StatusBadRequest, "请选择合法的业务日期;没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "没有收到 Excel(也可能超过 10MB);没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "无法打开上传文件;没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
head := make([]byte, 8)
|
||||
n, _ := io.ReadFull(src, head)
|
||||
if err := service.ValidateInnerCodeUpload(fileHeader.Filename, fileHeader.Size, head[:n]); err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error()+";没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||
fail(c, http.StatusInternalServerError, "读取上传文件失败;没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
uploadDir, err := config.SubDir("uploads")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "无法准备临时上传目录;没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(uploadDir, "inner-code-*.xlsx")
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, "无法保存临时上传文件;没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := io.Copy(tmp, src); err != nil {
|
||||
tmp.Close()
|
||||
fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。")
|
||||
return
|
||||
}
|
||||
result, err := service.ImportInnerCodeExcel(h.db, tmpPath, businessDate, currentUser(c).UserID)
|
||||
if err != nil {
|
||||
if service.IsInvalidInnerCodeImport(err) {
|
||||
fail(c, http.StatusBadRequest, err.Error()+";请修正文件后重新导入。")
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。")
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("导入完成:读取 %d 行,新增 %d 条,更新 %d 条,文件内重复 %d 行。",
|
||||
result.TotalRows, result.CreatedCount, result.UpdatedCount, result.DuplicateRows)
|
||||
h.innerCodeRedirect(c, businessDate, "", "", 1, message)
|
||||
}
|
||||
|
||||
// InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。
|
||||
func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
||||
businessDate := strings.TrimSpace(c.PostForm("date"))
|
||||
status, keyword := c.PostForm("status"), c.PostForm("q")
|
||||
pageNumber := service.ParsePage(c.PostForm("page"))
|
||||
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
|
||||
h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1, "业务日期无效,没有执行匹配。")
|
||||
return
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝配置不可用,没有执行匹配。")
|
||||
return
|
||||
}
|
||||
client, err := syb.New(cfg.Syb.BaseURL)
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝地址配置有误,没有执行匹配。")
|
||||
return
|
||||
}
|
||||
if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil {
|
||||
message := "校验顺运宝会话失败,没有执行匹配。"
|
||||
if errors.Is(err, service.ErrSybLoginRequired) {
|
||||
message = "顺运宝会话已过期,请先到“顺运宝数据”页面登录后再匹配。"
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
return
|
||||
}
|
||||
result, err := service.PlanInnerCodeRecords(c.Request.Context(), h.db, client, businessDate)
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "匹配失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("匹配完成:处理 %d 条,可回写 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条。",
|
||||
result.Total, result.Ready, result.AlreadyFilled, result.Skipped, result.Failed)
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
}
|
||||
|
||||
func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, message string) {
|
||||
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}, "message": {message}}
|
||||
if strings.TrimSpace(status) != "" {
|
||||
values.Set("status", strings.TrimSpace(status))
|
||||
}
|
||||
if strings.TrimSpace(keyword) != "" {
|
||||
values.Set("q", strings.TrimSpace(keyword))
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/inner-codes?"+values.Encode())
|
||||
}
|
||||
@@ -115,19 +115,25 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration, aiSecret
|
||||
clients.POST("/unassign", h.ClientUnassign)
|
||||
clients.POST("/delete", h.ClientDelete)
|
||||
|
||||
// 6. 用户管理:先经过网页登录,再叠加管理员角色校验。
|
||||
// 6. 档口入库码:独立于顺运宝数据页,导航位置紧跟客户端列表。
|
||||
pages.GET("/inner-codes", h.InnerCodeList)
|
||||
innerCodes := pages.Group("/inner-codes")
|
||||
innerCodes.POST("/import", h.InnerCodeImport)
|
||||
innerCodes.POST("/match", h.InnerCodeMatch)
|
||||
|
||||
// 7. 用户管理:先经过网页登录,再叠加管理员角色校验。
|
||||
users := pages.Group("/users", AdminRequired())
|
||||
users.GET("", h.UserList)
|
||||
users.POST("/create", h.UserCreate)
|
||||
users.POST("/status", h.UserSetStatus)
|
||||
users.POST("/reset-password", h.UserResetPassword)
|
||||
|
||||
// 7. 第三方商品目录导入记录:系统级审计仅管理员可见。
|
||||
// 8. 第三方商品目录导入记录:系统级审计仅管理员可见。
|
||||
integrations := pages.Group("/integrations", AdminRequired())
|
||||
integrations.GET("/catalog", h.CatalogHistory)
|
||||
integrations.GET("/catalog/detail", h.CatalogHistoryDetail)
|
||||
|
||||
// 8. AI 模型配置:密钥和系统级服务商配置仅管理员可见。
|
||||
// 9. AI 模型配置:密钥和系统级服务商配置仅管理员可见。
|
||||
aiSettings := pages.Group("/settings/ai", AdminRequired())
|
||||
aiSettings.GET("", h.AIConfigList)
|
||||
aiSettings.POST("/save", h.AIConfigSave)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
|
||||
if _, err := template.ParseFS(templateFS, "templates/*/*.html"); err != nil {
|
||||
t.Fatalf("模板必须可解析: %v", err)
|
||||
}
|
||||
header, err := os.ReadFile("templates/partials/header.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
headerText := string(header)
|
||||
clientIndex := strings.Index(headerText, `href="/clients"`)
|
||||
innerCodeIndex := strings.Index(headerText, `href="/inner-codes"`)
|
||||
if clientIndex < 0 || innerCodeIndex < 0 || innerCodeIndex < clientIndex {
|
||||
t.Fatal("档口入库码导航必须紧跟在客户端列表之后")
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile("templates/inner_code/list.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := string(raw)
|
||||
for _, want := range []string{
|
||||
`action="/inner-codes/import"`, `action="/inner-codes/match"`,
|
||||
`action="/inner-codes/apply"`, `action="/inner-codes/recheck"`,
|
||||
`name="csrf_token"`, `data-check-all`, `data-inner-code-apply-open`,
|
||||
`{{if not .CanSelect}}disabled{{end}}`, `aria-label="档口入库码记录列表"`,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("正式页面缺少 %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
|
||||
raw, err := os.ReadFile("static/js/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"/inner-codes": ["date", "status", "q", "page"]`) {
|
||||
t.Fatal("档口入库码页面切换模块后应保持稳定筛选和页码")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,20 @@ type InnerCodeSybSnapshot struct {
|
||||
SybData string
|
||||
}
|
||||
|
||||
// InnerCodeListFilter 是独立页面可组合的查询条件。
|
||||
type InnerCodeListFilter struct {
|
||||
BusinessDate string
|
||||
Status string
|
||||
Keyword string
|
||||
}
|
||||
|
||||
// InnerCodeStatusCounts 是当前业务日期的底栏摘要。
|
||||
type InnerCodeStatusCounts struct {
|
||||
Total int
|
||||
Ready int
|
||||
NeedsCheck int
|
||||
}
|
||||
|
||||
// InnerCodeImportOutcome 说明幂等导入是新增还是更新。
|
||||
type InnerCodeImportOutcome string
|
||||
|
||||
@@ -169,6 +183,97 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
|
||||
return nil
|
||||
}
|
||||
|
||||
const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequence,0),order_number,
|
||||
COALESCE(shop_name,''),stall,spec_raw,spec_key,inner_code,source_duplicate_count,
|
||||
COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
|
||||
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
|
||||
COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id,
|
||||
COALESCE(applied_by_user_id,''),COALESCE(planned_at,''),COALESCE(apply_started_at,''),
|
||||
COALESCE(applied_at,''),created_at,updated_at`
|
||||
|
||||
func innerCodeFilterClause(filter InnerCodeListFilter) (string, []any) {
|
||||
clauses := []string{"business_date=?"}
|
||||
args := []any{filter.BusinessDate}
|
||||
if filter.Status != "" {
|
||||
clauses = append(clauses, "status=?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
like := "%" + escapeLike(filter.Keyword) + "%"
|
||||
clauses = append(clauses, `(order_number LIKE ? ESCAPE '!' OR stall LIKE ? ESCAPE '!'
|
||||
OR spec_raw LIKE ? ESCAPE '!' OR inner_code LIKE ? ESCAPE '!')`)
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// ListInnerCodeRecords 分页读取独立页面记录。
|
||||
func ListInnerCodeRecords(q Execer, filter InnerCodeListFilter, limit, offset int) ([]model.InnerCodeRecord, error) {
|
||||
where, args := innerCodeFilterClause(filter)
|
||||
query := `SELECT ` + innerCodeListColumns + ` FROM syb_inner_code_records` + where +
|
||||
` ORDER BY source_row,id LIMIT ? OFFSET ?`
|
||||
args = append(args, limit, offset)
|
||||
rows, err := q.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询档口入库码列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]model.InnerCodeRecord, 0)
|
||||
for rows.Next() {
|
||||
row, err := scanInnerCodeRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("遍历档口入库码列表失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CountInnerCodeRecords 统计与列表完全相同的筛选结果。
|
||||
func CountInnerCodeRecords(q Execer, filter InnerCodeListFilter) (int, error) {
|
||||
where, args := innerCodeFilterClause(filter)
|
||||
var count int
|
||||
if err := q.QueryRow(`SELECT COUNT(*) FROM syb_inner_code_records`+where, args...).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计档口入库码列表失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CountInnerCodeStatuses 统计当前业务日期总量、可回写和需核对数量。
|
||||
func CountInnerCodeStatuses(q Execer, businessDate string) (InnerCodeStatusCounts, error) {
|
||||
var result InnerCodeStatusCounts
|
||||
err := q.QueryRow(`SELECT COUNT(*),
|
||||
COALESCE(SUM(status='ready'),0),COALESCE(SUM(status='needs_check'),0)
|
||||
FROM syb_inner_code_records WHERE business_date=?`, businessDate).
|
||||
Scan(&result.Total, &result.Ready, &result.NeedsCheck)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("统计档口入库码状态失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type innerCodeRowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, error) {
|
||||
var row model.InnerCodeRecord
|
||||
err := scanner.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence,
|
||||
&row.OrderNumber, &row.ShopName, &row.Stall, &row.SpecRaw, &row.SpecKey,
|
||||
&row.InnerCode, &row.SourceDuplicateCount, &row.StockID, &row.DetailID,
|
||||
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
|
||||
&row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage,
|
||||
&row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt,
|
||||
&row.AppliedAt, &row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
return row, fmt.Errorf("读取档口入库码记录失败: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func nullablePositiveInt(value int) any {
|
||||
if value <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
// InnerCodeStatusOption 是页面状态筛选项。
|
||||
type InnerCodeStatusOption struct {
|
||||
Value string
|
||||
Text string
|
||||
}
|
||||
|
||||
var innerCodeStatusOptions = []InnerCodeStatusOption{
|
||||
{"", "全部状态"},
|
||||
{string(model.InnerCodePending), "待匹配"},
|
||||
{string(model.InnerCodeReady), "可回写"},
|
||||
{string(model.InnerCodeApplying), "回写中"},
|
||||
{string(model.InnerCodeUpdated), "已回写"},
|
||||
{string(model.InnerCodeAlreadyFilled), "已存在"},
|
||||
{string(model.InnerCodeSkipped), "已跳过"},
|
||||
{string(model.InnerCodeFailed), "失败"},
|
||||
{string(model.InnerCodeNeedsCheck), "需核对"},
|
||||
}
|
||||
|
||||
// InnerCodeRowView 是正式页面的一行。
|
||||
type InnerCodeRowView struct {
|
||||
ID int64
|
||||
BusinessDate string
|
||||
OrderNumber string
|
||||
Stall string
|
||||
SpecRaw string
|
||||
SybSpec string
|
||||
InnerCode string
|
||||
RemoteInnerCode string
|
||||
Status string
|
||||
StatusText string
|
||||
StatusClass string
|
||||
ResultMessage string
|
||||
UpdatedAt string
|
||||
CanSelect bool
|
||||
HasRemoteOldCode bool
|
||||
CanRecheck bool
|
||||
}
|
||||
|
||||
// InnerCodeListPage 是列表、分页和底栏统计。
|
||||
type InnerCodeListPage struct {
|
||||
Rows []InnerCodeRowView
|
||||
Total int
|
||||
Page int
|
||||
TotalPages int
|
||||
Status string
|
||||
Keyword string
|
||||
Date string
|
||||
Counts repository.InnerCodeStatusCounts
|
||||
IsFiltered bool
|
||||
}
|
||||
|
||||
// InnerCodeStatusOptions 返回稳定顺序的筛选项副本。
|
||||
func InnerCodeStatusOptions() []InnerCodeStatusOption {
|
||||
return append([]InnerCodeStatusOption(nil), innerCodeStatusOptions...)
|
||||
}
|
||||
|
||||
// ListInnerCodePage 校验筛选并返回统一 20 条 SQL 分页结果。
|
||||
func ListInnerCodePage(db *sql.DB, businessDate, status, keyword string, requestedPage int) (*InnerCodeListPage, error) {
|
||||
status = strings.TrimSpace(status)
|
||||
if !validInnerCodeStatusFilter(status) {
|
||||
status = ""
|
||||
}
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
filter := repository.InnerCodeListFilter{BusinessDate: businessDate, Status: status, Keyword: keyword}
|
||||
total, err := repository.CountInnerCodeRecords(db, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalPages := TotalPages(total)
|
||||
page := ClampPage(requestedPage, totalPages)
|
||||
records, err := repository.ListInnerCodeRecords(db, filter, PageSize, (page-1)*PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := repository.CountInnerCodeStatuses(db, businessDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &InnerCodeListPage{Total: total, Page: page, TotalPages: totalPages,
|
||||
Status: status, Keyword: keyword, Date: businessDate, Counts: counts,
|
||||
IsFiltered: status != "" || keyword != ""}
|
||||
result.Rows = make([]InnerCodeRowView, 0, len(records))
|
||||
for _, record := range records {
|
||||
result.Rows = append(result.Rows, innerCodeRowView(record))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validInnerCodeStatusFilter(status string) bool {
|
||||
for _, option := range innerCodeStatusOptions {
|
||||
if option.Value == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
|
||||
statusText := map[model.InnerCodeStatus]string{
|
||||
model.InnerCodePending: "待匹配", model.InnerCodeReady: "可回写",
|
||||
model.InnerCodeApplying: "回写中", model.InnerCodeUpdated: "已回写",
|
||||
model.InnerCodeAlreadyFilled: "已存在", model.InnerCodeSkipped: "已跳过",
|
||||
model.InnerCodeFailed: "失败", model.InnerCodeNeedsCheck: "需核对",
|
||||
}[record.Status]
|
||||
if statusText == "" {
|
||||
statusText = "未知"
|
||||
}
|
||||
return InnerCodeRowView{
|
||||
ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber,
|
||||
Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw),
|
||||
SybSpec: displayInnerCodeValue(record.SybSpec), InnerCode: record.InnerCode,
|
||||
RemoteInnerCode: displayInnerCodeValue(record.RemoteInnerCode), Status: string(record.Status),
|
||||
StatusText: statusText, StatusClass: "inner-code-status-" + string(record.Status),
|
||||
ResultMessage: displayInnerCodeValue(record.ResultMessage), UpdatedAt: formatLocalTime(record.UpdatedAt),
|
||||
CanSelect: record.Status == model.InnerCodeReady,
|
||||
HasRemoteOldCode: record.Status == model.InnerCodeReady && record.RemoteInnerCode != "",
|
||||
CanRecheck: record.Status == model.InnerCodeNeedsCheck,
|
||||
}
|
||||
}
|
||||
|
||||
func displayInnerCodeValue(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "—"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// InnerCodeStatusMessage 生成底栏可读统计。
|
||||
func InnerCodeStatusMessage(page *InnerCodeListPage) string {
|
||||
return fmt.Sprintf("共 %d 条,可回写 %d 条,需核对 %d 条;当前筛选 %d 条",
|
||||
page.Counts.Total, page.Counts.Ready, page.Counts.NeedsCheck, page.Total)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
func TestInnerCodeRowView_只有可回写记录可选择(t *testing.T) {
|
||||
for _, status := range []model.InnerCodeStatus{
|
||||
model.InnerCodePending, model.InnerCodeReady, model.InnerCodeApplying,
|
||||
model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeSkipped,
|
||||
model.InnerCodeFailed, model.InnerCodeNeedsCheck,
|
||||
} {
|
||||
view := innerCodeRowView(model.InnerCodeRecord{Status: status, RemoteInnerCode: "OLD"})
|
||||
if view.CanSelect != (status == model.InnerCodeReady) {
|
||||
t.Errorf("status=%s CanSelect=%v", status, view.CanSelect)
|
||||
}
|
||||
if view.CanRecheck != (status == model.InnerCodeNeedsCheck) {
|
||||
t.Errorf("status=%s CanRecheck=%v", status, view.CanRecheck)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeStatusOptions_覆盖所有状态(t *testing.T) {
|
||||
options := InnerCodeStatusOptions()
|
||||
if len(options) != 9 || options[0].Value != "" {
|
||||
t.Fatalf("状态筛选项不完整: %+v", options)
|
||||
}
|
||||
}
|
||||
@@ -615,6 +615,30 @@ input.wide { width: 100%; }
|
||||
}
|
||||
.status-active { color: #1a7f37; background: #f0fff4; }
|
||||
.status-disabled { color: #777; background: #f3f4f6; }
|
||||
.inner-code-status-ready,
|
||||
.inner-code-status-updated,
|
||||
.inner-code-status-already_filled { color: #1a7f37; background: #f0fff4; }
|
||||
.inner-code-status-applying { color: #0969da; background: #eef6ff; }
|
||||
.inner-code-status-skipped,
|
||||
.inner-code-status-needs_check { color: #7a4b00; background: #fff8c5; }
|
||||
.inner-code-status-failed { color: #b42318; background: #fff1f0; }
|
||||
.inner-code-status-pending { color: #57606a; background: #f3f4f6; }
|
||||
.inner-code-toolbar { flex: 0 0 auto; }
|
||||
.inner-code-toolbar input[type="date"] { width: 138px; }
|
||||
.inner-code-toolbar .inner-code-search { width: 220px; }
|
||||
.inner-code-toolbar .file-field {
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #57606a;
|
||||
}
|
||||
.inner-code-notice { flex: 0 0 auto; margin: -4px 0 10px; }
|
||||
.inner-code-table-wrap { flex: 1 1 auto; min-height: 180px; overflow: auto; scrollbar-gutter: stable; }
|
||||
.inner-code-table { min-width: 1500px; }
|
||||
.inner-code-table th { position: sticky; top: 0; z-index: 1; }
|
||||
.inner-code-table .inner-code-message { min-width: 260px; max-width: 360px; }
|
||||
.mapping-source { white-space: nowrap; font-size: 12px; }
|
||||
.source-ai { color: #6f42c1; background: #f7f0ff; }
|
||||
.source-rule { color: #0969da; background: #eef6ff; }
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"/shops": [],
|
||||
"/tasks": ["type", "status", "creator", "q", "page"],
|
||||
"/clients": ["name", "page"],
|
||||
"/inner-codes": ["date", "status", "q", "page"],
|
||||
"/users": ["q", "status", "page"]
|
||||
};
|
||||
var MODULE_STATE_PREFIX = "cmautobuy:module-state:";
|
||||
@@ -348,6 +349,70 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* 档口入库码页只有轻量表单联动:一个可见业务日期同步给三个普通表单,
|
||||
并在确认弹窗里显示当前选中数量。匹配和回写规则仍全部在服务端。 */
|
||||
function setupInnerCodePage() {
|
||||
var root = document.querySelector("[data-inner-code-page]");
|
||||
if (!root) return;
|
||||
var dateInput = root.querySelector("[data-inner-code-date]");
|
||||
var fileInput = root.querySelector("[data-file-input]");
|
||||
var fileName = root.querySelector("[data-file-name]");
|
||||
var fileOpen = root.querySelector("[data-inner-code-file-open]");
|
||||
var applyButton = root.querySelector("[data-inner-code-apply-open]");
|
||||
|
||||
function syncDate() {
|
||||
document.querySelectorAll("[data-inner-code-date-field]").forEach(function (field) {
|
||||
field.value = dateInput ? dateInput.value : field.value;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectionSummary() {
|
||||
var selected = Array.prototype.slice.call(document.querySelectorAll(
|
||||
'input[name="ids"][form="inner-code-apply-form"]:checked:not(:disabled)'
|
||||
));
|
||||
var replacements = selected.filter(function (box) {
|
||||
return box.getAttribute("data-inner-code-old-value") === "1";
|
||||
}).length;
|
||||
document.querySelectorAll("[data-inner-code-selected-count], [data-inner-code-confirm-count]")
|
||||
.forEach(function (node) { node.textContent = selected.length; });
|
||||
document.querySelectorAll("[data-inner-code-replace-count]")
|
||||
.forEach(function (node) { node.textContent = replacements; });
|
||||
}
|
||||
|
||||
if (dateInput) dateInput.addEventListener("change", syncDate);
|
||||
if (fileOpen && fileInput) fileOpen.addEventListener("click", function () { fileInput.click(); });
|
||||
if (fileInput && fileName) {
|
||||
fileInput.addEventListener("change", function () {
|
||||
fileName.textContent = fileInput.files.length ? fileInput.files[0].name : "未选择文件";
|
||||
fileName.title = fileName.textContent;
|
||||
});
|
||||
}
|
||||
root.addEventListener("change", updateSelectionSummary);
|
||||
var table = document.querySelector(".inner-code-table");
|
||||
if (table) table.addEventListener("change", updateSelectionSummary);
|
||||
if (applyButton) applyButton.addEventListener("click", updateSelectionSummary);
|
||||
var matchForm = document.getElementById("inner-code-match-form");
|
||||
if (matchForm) matchForm.addEventListener("submit", function () {
|
||||
var button = document.querySelector('[form="inner-code-match-form"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-busy", "true");
|
||||
button.textContent = "匹配中…";
|
||||
}
|
||||
});
|
||||
var applyForm = document.getElementById("inner-code-apply-form");
|
||||
if (applyForm) applyForm.addEventListener("submit", function () {
|
||||
var button = document.querySelector('#inner-code-apply-modal button[type="submit"]');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute("aria-busy", "true");
|
||||
button.textContent = "回写中…";
|
||||
}
|
||||
});
|
||||
syncDate();
|
||||
updateSelectionSummary();
|
||||
}
|
||||
|
||||
/* ── 弹窗 ──────────────────────────────────
|
||||
弹窗**内容由服务端渲染**,这里只负责显示、隐藏和把内容取回来。
|
||||
不要在这里拼业务数据——价格格式、规格顺序都是业务规则,
|
||||
@@ -759,6 +824,7 @@
|
||||
setupPurchaseModal();
|
||||
setupCollectModals();
|
||||
setupUploadFeedback();
|
||||
setupInnerCodePage();
|
||||
setupModals();
|
||||
setupRowDetail();
|
||||
setupImagePreview();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
{{define "inner_code/list"}}
|
||||
{{template "header" .}}
|
||||
|
||||
<div class="toolbar inner-code-toolbar" data-inner-code-page>
|
||||
<div class="inline">
|
||||
<label for="inner-code-date">业务日期</label>
|
||||
<input id="inner-code-date" type="date" value="{{.BusinessDate}}" data-inner-code-date>
|
||||
</div>
|
||||
|
||||
<form class="inline" method="post" action="/inner-codes/import" enctype="multipart/form-data"
|
||||
data-upload-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
|
||||
<button type="button" data-inner-code-file-open>选择 Excel</button>
|
||||
<input id="inner-code-file" type="file" name="file" accept=".xlsx" required data-file-input hidden>
|
||||
<span class="file-field" data-file-name>未选择文件</span>
|
||||
<button type="submit" data-upload-submit>导入</button>
|
||||
</form>
|
||||
|
||||
<form id="inner-code-filter" class="inline grow" method="get" action="/inner-codes">
|
||||
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
|
||||
<label for="inner-code-status">状态</label>
|
||||
<select id="inner-code-status" name="status">
|
||||
{{range .StatusOptions}}<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>{{end}}
|
||||
</select>
|
||||
<label class="visually-hidden" for="inner-code-keyword">搜索订单号、档口、规格或入库码</label>
|
||||
<input id="inner-code-keyword" class="inner-code-search" type="text" name="q" value="{{.Keyword}}"
|
||||
placeholder="订单号 / 档口 / 规格 / 入库码">
|
||||
<button type="submit">搜索</button>
|
||||
<a class="button-link" href="/inner-codes?date={{.BusinessDate}}">清除</a>
|
||||
</form>
|
||||
|
||||
<form id="inner-code-match-form" method="post" action="/inner-codes/match" hidden>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
||||
<input type="hidden" name="q" value="{{.Keyword}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
</form>
|
||||
<button type="submit" class="primary" form="inner-code-match-form">匹配顺运宝商品</button>
|
||||
|
||||
<form id="inner-code-apply-form" method="post" action="/inner-codes/apply" hidden>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
||||
<input type="hidden" name="q" value="{{.Keyword}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
</form>
|
||||
<button type="button" data-modal-open="inner-code-apply-modal" data-need-checked="inner-code-apply"
|
||||
data-inner-code-apply-open disabled>回写已选 <span data-inner-code-selected-count>0</span> 条</button>
|
||||
</div>
|
||||
|
||||
{{if .Message}}<p class="notice inner-code-notice" role="status" aria-live="polite">{{.Message}}</p>{{end}}
|
||||
|
||||
<div class="table-wrap inner-code-table-wrap" role="region" aria-label="档口入库码记录列表" tabindex="0">
|
||||
<table class="inner-code-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页可回写记录"></th>
|
||||
<th>业务日期</th>
|
||||
<th>订单号</th>
|
||||
<th>档口及货号</th>
|
||||
<th>Excel 颜色尺码</th>
|
||||
<th>顺运宝规格</th>
|
||||
<th>档口入库码</th>
|
||||
<th>远端原值</th>
|
||||
<th>状态</th>
|
||||
<th>结果 / 原因</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}>
|
||||
<td class="col-check">
|
||||
<input type="checkbox" name="ids" value="{{.ID}}" form="inner-code-apply-form"
|
||||
data-select-action="inner-code-apply" data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}"
|
||||
aria-label="选择订单 {{.OrderNumber}}" {{if not .CanSelect}}disabled{{end}}>
|
||||
</td>
|
||||
<td>{{.BusinessDate}}</td>
|
||||
<td>{{.OrderNumber}}</td>
|
||||
<td class="truncate" title="{{.Stall}}">{{.Stall}}</td>
|
||||
<td class="truncate" title="{{.SpecRaw}}">{{.SpecRaw}}</td>
|
||||
<td class="truncate" title="{{.SybSpec}}">{{.SybSpec}}</td>
|
||||
<td>{{.InnerCode}}</td>
|
||||
<td>{{.RemoteInnerCode}}</td>
|
||||
<td><span class="status-pill {{.StatusClass}}">{{.StatusText}}</span></td>
|
||||
<td class="inner-code-message"><span class="truncate" title="{{.ResultMessage}}">{{.ResultMessage}}</span></td>
|
||||
<td>{{.UpdatedAt}}</td>
|
||||
<td>
|
||||
{{if .CanRecheck}}
|
||||
<form method="post" action="/inner-codes/recheck">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<input type="hidden" name="date" value="{{$.BusinessDate}}">
|
||||
<input type="hidden" name="status" value="{{$.StatusFilter}}">
|
||||
<input type="hidden" name="q" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
<button type="submit" class="link-button">重新核对</button>
|
||||
</form>
|
||||
{{else}}—{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="empty"><td colspan="12">
|
||||
{{if .IsFiltered}}
|
||||
当前筛选条件下没有记录。<br><small>请清除状态或关键词后再试。</small>
|
||||
{{else if .HasAny}}
|
||||
当前业务日期没有记录。<br><small>请选择其他日期,或导入这个日期的 Excel。</small>
|
||||
{{else}}
|
||||
还没有档口入库码记录。<br><small>选择业务日期并导入线下 Excel 后开始匹配。</small>
|
||||
{{end}}
|
||||
</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="inner-code-apply-modal" hidden>
|
||||
<section class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="inner-code-apply-title">
|
||||
<div class="modal-head">
|
||||
<h2 id="inner-code-apply-title">确认回写档口入库码</h2>
|
||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="confirm-box">将回写 <strong data-inner-code-confirm-count>0</strong> 条记录,其中
|
||||
<strong data-inner-code-replace-count>0</strong> 条有旧快递单号,需要先删除旧值再写入。</p>
|
||||
<ul>
|
||||
<li>每条写入前会重新读取顺运宝详情并核验商品。</li>
|
||||
<li>写入后会再次读取;只有远端值一致才标记“已回写”。</li>
|
||||
<li class="missing">超时或结果不确定时只标记“需核对”,不会自动重复写入。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" data-modal-close>取消</button>
|
||||
<button type="submit" class="primary" form="inner-code-apply-form">确认回写</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>{{.Title}} · 采集采购管理端</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body{{if eq .Active "syb"}} class="syb-page-body"{{end}}>
|
||||
<body{{if or (eq .Active "syb") (eq .Active "inner-codes")}} class="syb-page-body"{{end}}>
|
||||
|
||||
<nav class="nav" data-module-navigation data-ui-state-user="{{if .CurrentUser}}{{.CurrentUser.UserID}}{{end}}">
|
||||
<span class="nav-brand">采集采购管理端</span>
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="/syb" data-module-root="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
|
||||
<a href="/tasks" data-module-root="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采集采购</a>
|
||||
<a href="/clients" data-module-root="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
|
||||
<a href="/inner-codes" data-module-root="/inner-codes" class="{{if eq .Active "inner-codes"}}active{{end}}">档口入库码</a>
|
||||
{{if and .CurrentUser .CurrentUser.IsAdmin}}
|
||||
<a href="/shops" data-module-root="/shops" class="{{if eq .Active "shops"}}active{{end}}">店铺管理</a>
|
||||
<a href="/users" data-module-root="/users" class="{{if eq .Active "users"}}active{{end}}">用户管理</a>
|
||||
|
||||
Reference in New Issue
Block a user