feat: 优化批量匹配和分页默认值 (#244)
This commit is contained in:
@@ -14,12 +14,18 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/service"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
var innerCodeLocation = time.FixedZone("UTC+8", 8*60*60)
|
||||
|
||||
const (
|
||||
innerCodeFeedbackSuccess = "success"
|
||||
innerCodeFeedbackError = "error"
|
||||
)
|
||||
|
||||
// InnerCodeList 渲染独立的档口入库码工作台。
|
||||
func (h *Handler) InnerCodeList(c *gin.Context) {
|
||||
businessDate := strings.TrimSpace(c.Query("date"))
|
||||
@@ -28,6 +34,11 @@ func (h *Handler) InnerCodeList(c *gin.Context) {
|
||||
}
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
keyword := strings.TrimSpace(c.Query("q"))
|
||||
feedbackKind := strings.TrimSpace(c.Query("feedback"))
|
||||
feedbackMessage := strings.TrimSpace(c.Query("message"))
|
||||
if feedbackKind != innerCodeFeedbackSuccess && feedbackKind != innerCodeFeedbackError {
|
||||
feedbackKind, feedbackMessage = "", ""
|
||||
}
|
||||
pageSize := service.ParsePageSize(c.Query("page_size"))
|
||||
result, err := service.ListInnerCodePageWithPageSize(h.db, businessDate, status, keyword,
|
||||
service.ParsePage(c.Query("page")), pageSize)
|
||||
@@ -52,7 +63,9 @@ func (h *Handler) InnerCodeList(c *gin.Context) {
|
||||
"IsFiltered": result.IsFiltered,
|
||||
"CurrentPage": result.Page,
|
||||
"CurrentPageSize": pageSize,
|
||||
"Message": strings.TrimSpace(c.Query("message")),
|
||||
"FeedbackMessage": feedbackMessage,
|
||||
"FeedbackSuccess": feedbackMessage != "" && feedbackKind == innerCodeFeedbackSuccess,
|
||||
"FeedbackError": feedbackMessage != "" && feedbackKind == innerCodeFeedbackError,
|
||||
"Status": service.InnerCodeStatusMessage(result),
|
||||
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, query.Encode()),
|
||||
}))
|
||||
@@ -113,7 +126,7 @@ func (h *Handler) InnerCodeImport(c *gin.Context) {
|
||||
}
|
||||
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,同业务键合并 %d 行。",
|
||||
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.MergedRows)
|
||||
h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, message)
|
||||
h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, innerCodeFeedbackSuccess, message)
|
||||
}
|
||||
|
||||
// InnerCodeDelete 软删除当前页已选记录;不撤销任何顺运宝远端操作。
|
||||
@@ -123,10 +136,11 @@ func (h *Handler) InnerCodeDelete(c *gin.Context) {
|
||||
ids := parseInnerCodeIDs(c.PostFormArray("ids"))
|
||||
count, err := service.DeleteInnerCodes(h.db, ids, currentUser(c).UserID, time.Now())
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "删除失败:"+err.Error()+";没有部分删除,请刷新后重试。")
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
|
||||
"删除失败:"+err.Error()+";没有部分删除,请刷新后重试。")
|
||||
return
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber,
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackSuccess,
|
||||
fmt.Sprintf("已删除 %d 条档口入库码记录;顺运宝已写入的快递单号不会撤销。重新导入相同数据可恢复。", count))
|
||||
}
|
||||
|
||||
@@ -136,26 +150,26 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
||||
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, "业务日期无效,没有执行匹配。")
|
||||
h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1,
|
||||
innerCodeFeedbackError, "业务日期无效,没有执行匹配。")
|
||||
return
|
||||
}
|
||||
ids := parseInnerCodeIDs(c.PostFormArray("ids"))
|
||||
if len(ids) == 0 {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "请先勾选要匹配的记录。")
|
||||
return
|
||||
}
|
||||
if len(ids) > service.InnerCodeRemoteBatchLimit {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "一次最多匹配 20 条记录,请减少勾选后重试。")
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
|
||||
"请先勾选要匹配的记录。")
|
||||
return
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝配置不可用,没有执行匹配。")
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
|
||||
"顺运宝配置不可用,没有执行匹配。")
|
||||
return
|
||||
}
|
||||
client, err := syb.New(cfg.Syb.BaseURL)
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝地址配置有误,没有执行匹配。")
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
|
||||
"顺运宝地址配置有误,没有执行匹配。")
|
||||
return
|
||||
}
|
||||
autoLoggedIn, message := ensureInnerCodeMatchSession(
|
||||
@@ -163,12 +177,13 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
||||
func() (string, bool) { return h.attemptSybAutoLogin(c, client, *cfg) },
|
||||
)
|
||||
if message != "" {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
|
||||
return
|
||||
}
|
||||
result, err := service.PlanInnerCodeRecords(c.Request.Context(), h.db, client, businessDate, ids)
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "匹配失败:"+err.Error())
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
|
||||
"匹配失败:"+err.Error()+";本次没有保存不完整的匹配结果,请按提示处理后重试。")
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("匹配完成:处理 %d 条,可回写 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条。",
|
||||
@@ -176,7 +191,12 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
|
||||
if autoLoggedIn {
|
||||
message = "顺运宝自动登录成功;" + message
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
feedbackKind := innerCodeFeedbackSuccess
|
||||
if result.Failed > 0 || result.Skipped > 0 {
|
||||
feedbackKind = innerCodeFeedbackError
|
||||
message += " 请查看状态为“已跳过”或“失败”的记录,按逐行原因处理后重新勾选匹配。"
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message)
|
||||
}
|
||||
|
||||
// ensureInnerCodeMatchSession 只负责“检查缓存 → 必要时自动登录”的编排。
|
||||
@@ -207,17 +227,23 @@ func (h *Handler) InnerCodeApply(c *gin.Context) {
|
||||
ids := parseInnerCodeIDs(c.PostFormArray("ids"))
|
||||
client, message := h.innerCodeSybClient()
|
||||
if message != "" {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
|
||||
return
|
||||
}
|
||||
result, err := service.ApplyInnerCodes(c.Request.Context(), h.db, client, ids, currentUser(c).UserID)
|
||||
if err != nil {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "回写未完成:"+err.Error())
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
|
||||
"回写未完成:"+err.Error())
|
||||
return
|
||||
}
|
||||
message = fmt.Sprintf("回写完成:选择 %d 条,成功 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条,需核对 %d 条。",
|
||||
result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck)
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
feedbackKind := innerCodeFeedbackSuccess
|
||||
if result.Failed > 0 || result.NeedsCheck > 0 {
|
||||
feedbackKind = innerCodeFeedbackError
|
||||
message += " 请查看失败或需核对的记录,确认远端结果后再继续。"
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message)
|
||||
}
|
||||
|
||||
func parseInnerCodeIDs(rawIDs []string) []int64 {
|
||||
@@ -240,19 +266,26 @@ func (h *Handler) InnerCodeRecheck(c *gin.Context) {
|
||||
pageNumber := service.ParsePage(c.PostForm("page"))
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "记录编号无效,没有执行核对。")
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
|
||||
"记录编号无效,没有执行核对。")
|
||||
return
|
||||
}
|
||||
client, message := h.innerCodeSybClient()
|
||||
if message != "" {
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
|
||||
return
|
||||
}
|
||||
_, message, err = service.RecheckInnerCode(c.Request.Context(), h.db, client, id)
|
||||
recheckStatus, message, err := service.RecheckInnerCode(c.Request.Context(), h.db, client, id)
|
||||
if err != nil {
|
||||
message = "重新核对失败:" + err.Error()
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
|
||||
return
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
|
||||
feedbackKind := innerCodeFeedbackSuccess
|
||||
if recheckStatus == model.InnerCodeNeedsCheck {
|
||||
feedbackKind = innerCodeFeedbackError
|
||||
}
|
||||
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message)
|
||||
}
|
||||
|
||||
func (h *Handler) innerCodeSybClient() (*syb.Client, string) {
|
||||
@@ -273,8 +306,12 @@ func (h *Handler) innerCodeSybClient() (*syb.Client, string) {
|
||||
return client, ""
|
||||
}
|
||||
|
||||
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}}
|
||||
func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, feedbackKind, message string) {
|
||||
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}}
|
||||
if message = strings.TrimSpace(message); message != "" {
|
||||
values.Set("feedback", feedbackKind)
|
||||
values.Set("message", message)
|
||||
}
|
||||
values.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
|
||||
if strings.TrimSpace(status) != "" {
|
||||
values.Set("status", strings.TrimSpace(status))
|
||||
|
||||
@@ -2,9 +2,15 @@ package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/service"
|
||||
)
|
||||
|
||||
@@ -15,6 +21,45 @@ func TestParseInnerCodeIDs_过滤无效并保持首次选择顺序(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeRedirect_区分错误反馈并使用默认100条(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
form := url.Values{"page_size": {""}}
|
||||
context.Request = httptest.NewRequest(http.MethodPost, "/inner-codes/match", strings.NewReader(form.Encode()))
|
||||
context.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
(&Handler{}).innerCodeRedirect(context, "2026-08-15", "pending", "ORDER-1", 2,
|
||||
innerCodeFeedbackError, "匹配失败:测试错误")
|
||||
if context.Writer.Status() != http.StatusSeeOther {
|
||||
t.Fatalf("状态码=%d,期望 303", context.Writer.Status())
|
||||
}
|
||||
location, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := location.Query()
|
||||
if location.Path != "/inner-codes" || query.Get("feedback") != "error" ||
|
||||
query.Get("message") != "匹配失败:测试错误" || query.Get("page_size") != "100" ||
|
||||
query.Get("date") != "2026-08-15" || query.Get("status") != "pending" ||
|
||||
query.Get("q") != "ORDER-1" || query.Get("page") != "2" {
|
||||
t.Fatalf("反馈跳转参数不完整: %s", recorder.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeMatch_不再按20条提前拒绝只读匹配(t *testing.T) {
|
||||
raw, err := os.ReadFile("inner_code.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(raw)
|
||||
for _, forbidden := range []string{"InnerCodeRemoteBatchLimit", "一次最多匹配 20 条"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Errorf("只读匹配仍包含旧限制 %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInnerCodeMatchSession_有效会话不自动登录(t *testing.T) {
|
||||
loginCalls := 0
|
||||
autoLoggedIn, message := ensureInnerCodeMatchSession(
|
||||
|
||||
@@ -101,7 +101,7 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) {
|
||||
func Test列表写操作跳转归一化非法每页条数(t *testing.T) {
|
||||
context, recorder := listPostContext(t, "/pdd/delete", url.Values{"page_size": {"9999"}})
|
||||
(&Handler{}).pddRedirect(context, "")
|
||||
assertRedirectQuery(t, recorder, "/pdd", map[string]string{"page_size": "20"})
|
||||
assertRedirectQuery(t, recorder, "/pdd", map[string]string{"page_size": "100"})
|
||||
}
|
||||
|
||||
func Test蝦皮分页详情和状态条保留分类搜索(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -35,6 +36,8 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
|
||||
`data-need-checked="inner-code-delete"`, `data-confirm-delete-action="inner-code-delete"`,
|
||||
`inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply`,
|
||||
`删除不会撤销顺运宝已写入的快递单号`, `匹配已选`, `aria-label="档口入库码记录列表"`,
|
||||
`data-auto-dismiss-toast data-transient-feedback`, `role="alertdialog"`,
|
||||
`id="inner-code-feedback-error-modal" data-auto-open-modal`,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("正式页面缺少 %s", want)
|
||||
@@ -43,6 +46,62 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
|
||||
if strings.Contains(page, `{{if not .CanMatch}}disabled{{end}}`) {
|
||||
t.Fatal("所有状态都应允许勾选删除,不能再按 CanMatch 禁用复选框")
|
||||
}
|
||||
if strings.Contains(page, `inner-code-notice`) {
|
||||
t.Fatal("档口入库码反馈不应继续占用列表上方布局")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeTemplate_错误弹窗自动转义且成功提示不占布局(t *testing.T) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
data := map[string]any{
|
||||
"Title": "档口入库码", "Active": "inner-codes", "CSRFToken": "test-csrf",
|
||||
"FeedbackError": true, "FeedbackMessage": `<script>alert("secret")</script>`,
|
||||
}
|
||||
if err := tmpl.ExecuteTemplate(&output, "inner_code/list", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := output.String()
|
||||
for _, want := range []string{
|
||||
`role="alertdialog"`, `data-auto-open-modal`,
|
||||
`<script>alert("secret")</script>`,
|
||||
`系统不会因本弹窗自动重复匹配或回写`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("错误反馈弹窗缺少 %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `<script>alert("secret")</script>`) {
|
||||
t.Fatal("错误反馈未经 html/template 自动转义")
|
||||
}
|
||||
|
||||
rawJS, err := os.ReadFile("static/js/app.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
js := string(rawJS)
|
||||
for _, want := range []string{
|
||||
`function setupTransientFeedback()`, `data-auto-dismiss-toast`,
|
||||
`window.setTimeout(function ()`, `}, 4000);`,
|
||||
`current.searchParams.delete(name)`, `window.history.replaceState`,
|
||||
`setupTransientFeedback();`,
|
||||
} {
|
||||
if !strings.Contains(js, want) {
|
||||
t.Errorf("临时反馈脚本缺少 %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
cssRaw, err := os.ReadFile("static/css/app.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
css := string(cssRaw)
|
||||
if !strings.Contains(css, `.inner-code-toast {`) || !strings.Contains(css, `position: fixed;`) {
|
||||
t.Fatal("成功提示必须固定定位,不能占用表格布局")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
|
||||
|
||||
+7
-4
@@ -101,7 +101,7 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSizeSelect_切换后自动提交且保留无脚本回退(t *testing.T) {
|
||||
func TestPageSizeSelect_切换后自动提交且无可见应用按钮(t *testing.T) {
|
||||
footer, err := os.ReadFile("templates/partials/footer.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -110,12 +110,15 @@ func TestPageSizeSelect_切换后自动提交且保留无脚本回退(t *testing
|
||||
for _, want := range []string{
|
||||
`method="get" class="page-size-form" data-page-size-form`,
|
||||
`name="page_size" data-page-size-select`,
|
||||
`type="submit" data-page-size-submit>应用</button>`,
|
||||
`data-page-size-loading role="status" hidden>加载中…</span>`,
|
||||
} {
|
||||
if !strings.Contains(footerSource, want) {
|
||||
t.Errorf("公共分页缺少渐进增强回退 %q", want)
|
||||
t.Errorf("公共分页缺少自动刷新结构 %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(footerSource, `data-page-size-submit`) || strings.Contains(footerSource, `>应用</button>`) {
|
||||
t.Fatal("公共分页不应继续显示重复的应用按钮")
|
||||
}
|
||||
|
||||
js, err := os.ReadFile("static/js/app.js")
|
||||
if err != nil {
|
||||
@@ -128,7 +131,7 @@ func TestPageSizeSelect_切换后自动提交且保留无脚本回退(t *testing
|
||||
`form.getAttribute("aria-busy") === "true"`,
|
||||
`form.requestSubmit()`,
|
||||
`if (beginSubmit()) form.submit()`,
|
||||
`submit.textContent = "加载中…"`,
|
||||
`loading.hidden = false`,
|
||||
`setupPageSizeForms();`,
|
||||
} {
|
||||
if !strings.Contains(jsSource, want) {
|
||||
|
||||
@@ -42,8 +42,8 @@ func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, id
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("没有选择可回写记录")
|
||||
}
|
||||
if len(ids) > InnerCodeRemoteBatchLimit {
|
||||
return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeRemoteBatchLimit)
|
||||
if len(ids) > InnerCodeApplyBatchLimit {
|
||||
return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeApplyBatchLimit)
|
||||
}
|
||||
result := &InnerCodeApplyResult{Requested: len(ids)}
|
||||
for _, id := range ids {
|
||||
|
||||
@@ -21,7 +21,7 @@ type fakeInnerCodeWriter struct {
|
||||
}
|
||||
|
||||
func TestApplyInnerCodes_远程批量上限不随页面容量放宽(t *testing.T) {
|
||||
ids := make([]int64, InnerCodeRemoteBatchLimit+1)
|
||||
ids := make([]int64, InnerCodeApplyBatchLimit+1)
|
||||
for i := range ids {
|
||||
ids[i] = int64(i + 1)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ type InnerCodeDetailReader interface {
|
||||
DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error)
|
||||
}
|
||||
|
||||
const innerCodeReadBatchSize = 100
|
||||
|
||||
// InnerCodePlanResult 是一次规划的逐状态统计。
|
||||
type InnerCodePlanResult struct {
|
||||
Total int
|
||||
@@ -65,9 +67,35 @@ func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetai
|
||||
}
|
||||
stockIDsByOrder := innerCodeStockIDsByOrder(snapshots)
|
||||
requested := uniqueInnerCodeStockIDs(records, stockIDsByOrder)
|
||||
detailsByID, err := readInnerCodeDetails(ctx, reader, requested)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plans := planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, reservedDetails)
|
||||
for _, plan := range plans {
|
||||
switch plan.Status {
|
||||
case model.InnerCodeReady:
|
||||
result.Ready++
|
||||
case model.InnerCodeAlreadyFilled:
|
||||
result.AlreadyFilled++
|
||||
case model.InnerCodeFailed:
|
||||
result.Failed++
|
||||
default:
|
||||
result.Skipped++
|
||||
}
|
||||
}
|
||||
if err := repository.SaveInnerCodePlans(db, plans, model.NowISO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// readInnerCodeDetails 只限制单次顺运宝请求大小,不限制一次规划的总选择数量。
|
||||
func readInnerCodeDetails(ctx context.Context, reader InnerCodeDetailReader, requested []int64) (map[int64]syb.StockDetail, error) {
|
||||
detailsByID := make(map[int64]syb.StockDetail, len(requested))
|
||||
for start := 0; start < len(requested); start += 100 {
|
||||
end := start + 100
|
||||
for start := 0; start < len(requested); start += innerCodeReadBatchSize {
|
||||
end := start + innerCodeReadBatchSize
|
||||
if end > len(requested) {
|
||||
end = len(requested)
|
||||
}
|
||||
@@ -88,24 +116,7 @@ func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetai
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plans := planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, reservedDetails)
|
||||
for _, plan := range plans {
|
||||
switch plan.Status {
|
||||
case model.InnerCodeReady:
|
||||
result.Ready++
|
||||
case model.InnerCodeAlreadyFilled:
|
||||
result.AlreadyFilled++
|
||||
case model.InnerCodeFailed:
|
||||
result.Failed++
|
||||
default:
|
||||
result.Skipped++
|
||||
}
|
||||
}
|
||||
if err := repository.SaveInnerCodePlans(db, plans, model.NowISO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
return detailsByID, nil
|
||||
}
|
||||
|
||||
func uniqueInnerCodeOrders(records []model.InnerCodeRecord) []string {
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/syb"
|
||||
)
|
||||
|
||||
type batchingInnerCodeReader struct {
|
||||
batches [][]int64
|
||||
}
|
||||
|
||||
func (r *batchingInnerCodeReader) DetailListByStock(_ context.Context, ids []int64) ([]syb.StockDetail, error) {
|
||||
r.batches = append(r.batches, append([]int64(nil), ids...))
|
||||
result := make([]syb.StockDetail, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
result = append(result, syb.StockDetail{ID: id})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func TestReadInnerCodeDetails_总量不限且每批最多100(t *testing.T) {
|
||||
ids := make([]int64, 205)
|
||||
for index := range ids {
|
||||
ids[index] = int64(index + 1)
|
||||
}
|
||||
reader := &batchingInnerCodeReader{}
|
||||
details, err := readInnerCodeDetails(context.Background(), reader, ids)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(details) != len(ids) || len(reader.batches) != 3 {
|
||||
t.Fatalf("只读匹配应处理全部选择并分 3 批,details=%d batches=%d", len(details), len(reader.batches))
|
||||
}
|
||||
for index, want := range []int{100, 100, 5} {
|
||||
if len(reader.batches[index]) != want {
|
||||
t.Errorf("第 %d 批数量=%d,期望 %d", index+1, len(reader.batches[index]), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerCodeStockIDFromJSON(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"stock":{"id":75104587},"detail":{"id":1}}`,
|
||||
|
||||
@@ -27,11 +27,11 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{
|
||||
{string(model.InnerCodeNeedsCheck), "需核对"},
|
||||
}
|
||||
|
||||
// 档口入库码的批量上限是业务安全限制,不能再复用界面每页条数。
|
||||
// 删除只改本地数据库,可覆盖最大单页;匹配和回写会访问顺运宝,继续限制 20 条。
|
||||
// 删除只改本地数据库,可覆盖最大单页;回写会修改顺运宝,继续限制 20 条。
|
||||
// 只读匹配不使用这个写操作上限,见 inner_code_match.go 的远端读取分批。
|
||||
const (
|
||||
InnerCodeDeleteBatchLimit = 100
|
||||
InnerCodeRemoteBatchLimit = 20
|
||||
InnerCodeApplyBatchLimit = 20
|
||||
)
|
||||
|
||||
// InnerCodeRowView 是正式页面的一行。
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
|
||||
// DefaultPageSize 是主列表没有指定 page_size 时使用的每页条数。
|
||||
//
|
||||
// 20 行在 1366×768 上通常不需要滚动,因此继续作为默认值;实际页面请求必须
|
||||
// 通过 ParsePageSize 白名单解析。
|
||||
const DefaultPageSize = 20
|
||||
// 采购员通常需要在同一页批量勾选较多记录,因此默认显示白名单中的最大值 100;
|
||||
// 实际页面请求仍必须通过 ParsePageSize 白名单解析,不能把任意输入交给 SQL。
|
||||
const DefaultPageSize = 100
|
||||
|
||||
var allowedPageSizes = [...]int{20, 50, 100}
|
||||
|
||||
@@ -34,7 +34,7 @@ func ParsePage(s string) int {
|
||||
}
|
||||
|
||||
// ParsePageSize 解析 URL 上的 page_size,只接受界面提供的三个白名单值。
|
||||
// 非法值回退为 20,避免有人手改 URL 造成一次读取过多数据。
|
||||
// 非法值回退为默认 100;SQL 查询仍只可能使用固定白名单值。
|
||||
func ParsePageSize(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
|
||||
@@ -34,6 +34,12 @@ func TestParsePageSize_只接受白名单(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageSize_缺失时默认100(t *testing.T) {
|
||||
if DefaultPageSize != 100 || ParsePageSize("") != 100 {
|
||||
t.Fatalf("公共分页缺失参数时应默认 100,DefaultPageSize=%d got=%d", DefaultPageSize, ParsePageSize(""))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalPages_总数为0返回1不是0(t *testing.T) {
|
||||
if got := TotalPages(0, 100); got != 1 {
|
||||
t.Errorf("TotalPages(0) = %d,想要 1(不能出现『第 1/0 页』)", got)
|
||||
@@ -46,7 +52,7 @@ func TestTotalPages_按PageSize向上取整(t *testing.T) {
|
||||
DefaultPageSize: 1,
|
||||
DefaultPageSize + 1: 2,
|
||||
DefaultPageSize * 2: 2,
|
||||
5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页
|
||||
5195: 52, // 工单 #43 实测样本量:5195 条 / 100 条一页 = 52 页
|
||||
}
|
||||
for total, want := range cases {
|
||||
if got := TotalPages(total, DefaultPageSize); got != want {
|
||||
|
||||
@@ -639,7 +639,20 @@ input.wide { width: 100%; }
|
||||
white-space: nowrap;
|
||||
color: #57606a;
|
||||
}
|
||||
.inner-code-notice { flex: 0 0 auto; margin: -4px 0 10px; }
|
||||
.inner-code-toast {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 56px;
|
||||
z-index: 9;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #9ec5a8;
|
||||
border-radius: 4px;
|
||||
color: #155724;
|
||||
background: #f0fff4;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, .16);
|
||||
}
|
||||
.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; }
|
||||
@@ -688,13 +701,12 @@ select {
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.page-size-form select,
|
||||
.page-size-form button {
|
||||
.page-size-form select {
|
||||
min-height: 28px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.page-size-form select { padding: 3px 6px; }
|
||||
.page-size-form button { padding: 3px 8px; }
|
||||
.page-size-loading { font-size: 12px; color: #57606a; }
|
||||
.pagination a,
|
||||
.pagination span.disabled {
|
||||
padding: 4px 10px;
|
||||
|
||||
+31
-8
@@ -93,22 +93,18 @@
|
||||
}
|
||||
|
||||
/* ── 每页条数立即生效 ─────────────────────────
|
||||
普通 GET 表单仍是无 JavaScript 时的完整回退;脚本只做渐进增强,让采购员
|
||||
选择 20/50/100 后不用再点一次“应用”。select 不能禁用,否则浏览器提交时
|
||||
会丢掉 page_size。 */
|
||||
采购员选择 20/50/100 后直接提交 GET 表单。select 不能禁用,否则浏览器
|
||||
提交时会丢掉 page_size。 */
|
||||
function setupPageSizeForms() {
|
||||
document.querySelectorAll("[data-page-size-form]").forEach(function (form) {
|
||||
var select = form.querySelector("[data-page-size-select]");
|
||||
var submit = form.querySelector("[data-page-size-submit]");
|
||||
var loading = form.querySelector("[data-page-size-loading]");
|
||||
if (!select) return;
|
||||
|
||||
function beginSubmit() {
|
||||
if (form.getAttribute("aria-busy") === "true") return false;
|
||||
form.setAttribute("aria-busy", "true");
|
||||
if (submit) {
|
||||
submit.disabled = true;
|
||||
submit.textContent = "加载中…";
|
||||
}
|
||||
if (loading) loading.hidden = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -126,6 +122,32 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* 操作结果通过 POST/Redirect/GET 返回。成功提示固定定位并自动消失,错误交给
|
||||
通用弹窗;两者都不占表格高度。页面读到结果后清掉临时查询参数,避免 F5
|
||||
重复弹出同一条反馈。 */
|
||||
function setupTransientFeedback() {
|
||||
var feedbacks = document.querySelectorAll("[data-transient-feedback]");
|
||||
if (!feedbacks.length) return;
|
||||
|
||||
var paramsToRemove = {};
|
||||
feedbacks.forEach(function (feedback) {
|
||||
(feedback.getAttribute("data-transient-query") || "").split(/\s+/).forEach(function (name) {
|
||||
if (name) paramsToRemove[name] = true;
|
||||
});
|
||||
if (feedback.hasAttribute("data-auto-dismiss-toast")) {
|
||||
window.setTimeout(function () {
|
||||
feedback.hidden = true;
|
||||
feedback.remove();
|
||||
}, 4000);
|
||||
}
|
||||
});
|
||||
|
||||
var current = new URL(window.location.href);
|
||||
Object.keys(paramsToRemove).forEach(function (name) { current.searchParams.delete(name); });
|
||||
var cleanURL = current.pathname + (current.search ? current.search : "") + current.hash;
|
||||
window.history.replaceState(window.history.state, "", cleanURL);
|
||||
}
|
||||
|
||||
/* ── 全选 / 反选 ──────────────────────────────
|
||||
表头的勾选框控制本表格所有行的勾选框。 */
|
||||
function setupCheckAll(root) {
|
||||
@@ -888,6 +910,7 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
setupModuleNavigationState();
|
||||
setupPageSizeForms();
|
||||
setupTransientFeedback();
|
||||
document.querySelectorAll("table").forEach(setupCheckAll);
|
||||
setupConfirmDelete();
|
||||
setupConfirmSubmit();
|
||||
|
||||
@@ -63,7 +63,33 @@
|
||||
data-need-checked="inner-code-delete" disabled>删除已选 <span data-inner-code-delete-count>0</span> 条</button>
|
||||
</div>
|
||||
|
||||
{{if .Message}}<p class="notice inner-code-notice" role="status" aria-live="polite">{{.Message}}</p>{{end}}
|
||||
{{if .FeedbackSuccess}}
|
||||
<div class="notice inner-code-toast" role="status" aria-live="polite"
|
||||
data-auto-dismiss-toast data-transient-feedback data-transient-query="feedback message">
|
||||
{{.FeedbackMessage}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .FeedbackError}}
|
||||
<div class="modal-backdrop" id="inner-code-feedback-error-modal" data-auto-open-modal
|
||||
data-transient-feedback data-transient-query="feedback message" hidden>
|
||||
<section class="modal modal-narrow" role="alertdialog" aria-modal="true"
|
||||
aria-labelledby="inner-code-feedback-error-title"
|
||||
aria-describedby="inner-code-feedback-error-message">
|
||||
<div class="modal-head">
|
||||
<h2 id="inner-code-feedback-error-title">档口入库码操作未完成</h2>
|
||||
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="missing" id="inner-code-feedback-error-message">{{.FeedbackMessage}}</p>
|
||||
<p class="hint">系统不会因本弹窗自动重复匹配或回写。请按提示处理后重新选择记录。</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button type="button" data-modal-close autofocus>关闭</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="table-wrap inner-code-table-wrap" role="region" aria-label="档口入库码记录列表" tabindex="0">
|
||||
<table class="inner-code-table">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" data-page-size-submit>应用</button>
|
||||
<span class="page-size-loading" data-page-size-loading role="status" hidden>加载中…</span>
|
||||
</form>
|
||||
{{if .Pagination.HasPrev}}
|
||||
<a href="{{.Pagination.FirstURL}}">首页</a>
|
||||
|
||||
@@ -95,8 +95,8 @@
|
||||
### 3.2 分页
|
||||
|
||||
`[必须]` 使用公共分页的列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户、
|
||||
商品目录导入记录、档口入库码)统一提供 **20 / 50 / 100 条**三档,默认 20。
|
||||
20 行在 1366×768 上通常不需要滚动;50 和 100 用于集中查看或批量勾选,最大值
|
||||
商品目录导入记录、档口入库码)统一提供 **20 / 50 / 100 条**三档,默认 100。
|
||||
默认 100 便于集中查看和批量勾选;20 和 50 用于降低单页密度,最大值
|
||||
固定为 100,不提供“全部”或任意自定义数量。顺运宝同步记录是紧凑弹窗,继续固定
|
||||
每页 10 条;顺运宝上游接口的抓取页容量也不是这里的界面设置。
|
||||
|
||||
@@ -111,12 +111,12 @@
|
||||
- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西
|
||||
不要 0-based。
|
||||
- `[必须]` 每页条数参数是 `?page_size=N`,只接受 20、50、100;缺失、非数字或
|
||||
其他值都回退为 20,不能把用户输入直接交给 SQL `LIMIT`。
|
||||
其他值都回退为 100,不能把用户输入直接交给 SQL `LIMIT`。
|
||||
- `[必须]` 修改每页条数时回到第 1 页;筛选、翻页、写操作跳转、F5、浏览器
|
||||
前进后退和模块状态恢复都保留归一化后的 `page_size`。
|
||||
- `[必须]` 选择 20、50、100 后立即提交 GET 表单并刷新,不要求采购员再点一次
|
||||
“应用”。自动提交要显示加载状态并防重复;“应用”按钮仍保留为 JavaScript
|
||||
不可用时的回退,不能把分页变成只有脚本才能使用的功能。
|
||||
- `[必须]` 选择 20、50、100 后立即提交 GET 表单并刷新,不显示重复的“应用”按钮。
|
||||
自动提交要显示加载状态并防重复;JavaScript 不可用时仍可使用已有分页链接,
|
||||
只是不能切换每页条数。
|
||||
- `[必须]` 越界要兜住:
|
||||
- `page` 非数字、`0`、负数 → 当作第 1 页;
|
||||
- `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格;
|
||||
@@ -136,8 +136,8 @@
|
||||
- `[必须]` 表格勾选只代表当前页。翻页后不保留勾选,也不提供跨页批量操作,
|
||||
避免操作员看不到将被处理的记录。
|
||||
- `[必须]` 每页条数只控制读取和展示,不能充当外部写操作的安全上限。档口入库码
|
||||
匹配、回写等顺运宝远程操作继续使用独立的单次上限 20;修改页面容量不能自动
|
||||
放宽它。
|
||||
只读匹配允许处理当前页全部选中记录,内部每次最多读取 100 个顺运宝货运单;
|
||||
回写会修改远端数据,继续使用独立的单次上限 20。
|
||||
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
|
||||
操作员应该靠筛选定位,不是靠翻页数页码。
|
||||
|
||||
@@ -959,6 +959,8 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固
|
||||
| 删除 | 二次确认框,写明"将删除 N 条,不可恢复" |
|
||||
| 客户端绑定/转交 | 弹窗确认目标采购员;成功后刷新负责人并显示结果 |
|
||||
| 客户端解绑 | 二次确认,写明可见范围立即变化、历史和既有任务保留 |
|
||||
| 档口入库码操作成功 | 固定定位短提示,3–5 秒自动消失,不占表格高度 |
|
||||
| 档口入库码匹配失败或部分失败 | 自动打开错误弹窗,说明失败数量、原因和重新处理方式 |
|
||||
|
||||
`[必须]` 报错要说清**哪一步失败、下一步做什么**,不要把 Go 的错误堆栈贴到页面上。
|
||||
堆栈写日志。
|
||||
|
||||
Reference in New Issue
Block a user