diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go new file mode 100644 index 0000000..5e20c83 --- /dev/null +++ b/admin/handler/web/inner_code.go @@ -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()) +} diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 0b1daab..fc32646 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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) diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go new file mode 100644 index 0000000..6f9c167 --- /dev/null +++ b/admin/inner_code_template_test.go @@ -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("档口入库码页面切换模块后应保持稳定筛选和页码") + } +} diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index b9f5202..2cf81e7 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -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 diff --git a/admin/service/inner_code_page.go b/admin/service/inner_code_page.go new file mode 100644 index 0000000..33f2821 --- /dev/null +++ b/admin/service/inner_code_page.go @@ -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) +} diff --git a/admin/service/inner_code_page_test.go b/admin/service/inner_code_page_test.go new file mode 100644 index 0000000..696035a --- /dev/null +++ b/admin/service/inner_code_page_test.go @@ -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) + } +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css index a0afba4..d136160 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -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; } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 0c2acb2..2f4acdb 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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(); diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html new file mode 100644 index 0000000..89023e7 --- /dev/null +++ b/admin/templates/inner_code/list.html @@ -0,0 +1,143 @@ +{{define "inner_code/list"}} +{{template "header" .}} + +
+ +{{if .Message}}{{.Message}}
{{end}} + +| + | 业务日期 | +订单号 | +档口及货号 | +Excel 颜色尺码 | +顺运宝规格 | +档口入库码 | +远端原值 | +状态 | +结果 / 原因 | +更新时间 | +操作 | +
|---|---|---|---|---|---|---|---|---|---|---|---|
| + + | +{{.BusinessDate}} | +{{.OrderNumber}} | +{{.Stall}} | +{{.SpecRaw}} | +{{.SybSpec}} | +{{.InnerCode}} | +{{.RemoteInnerCode}} | +{{.StatusText}} | + +{{.UpdatedAt}} | ++ {{if .CanRecheck}} + + {{else}}—{{end}} + | +|
|
+ {{if .IsFiltered}}
+ 当前筛选条件下没有记录。 请清除状态或关键词后再试。 + {{else if .HasAny}} + 当前业务日期没有记录。 请选择其他日期,或导入这个日期的 Excel。 + {{else}} + 还没有档口入库码记录。 选择业务日期并导入线下 Excel 后开始匹配。 + {{end}} + | |||||||||||
将回写 0 条记录,其中 + 0 条有旧快递单号,需要先删除旧值再写入。
+