diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index a969f72..255243e 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -127,6 +127,15 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) { h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1, "业务日期无效,没有执行匹配。") return } + ids := parseInnerCodeIDs(c.PostFormArray("ids")) + if len(ids) == 0 { + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "请先勾选要匹配的记录。") + return + } + if len(ids) > service.PageSize { + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "一次最多匹配当前页 20 条记录,请刷新后重新勾选。") + return + } cfg, err := config.Load() if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝配置不可用,没有执行匹配。") @@ -145,7 +154,7 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) return } - result, err := service.PlanInnerCodeRecords(c.Request.Context(), h.db, client, businessDate) + result, err := service.PlanInnerCodeRecords(c.Request.Context(), h.db, client, businessDate, ids) if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "匹配失败:"+err.Error()) return @@ -159,12 +168,7 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) { func (h *Handler) InnerCodeApply(c *gin.Context) { businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") pageNumber := service.ParsePage(c.PostForm("page")) - ids := make([]int64, 0) - for _, raw := range c.PostFormArray("ids") { - if id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); err == nil && id > 0 { - ids = append(ids, id) - } - } + ids := parseInnerCodeIDs(c.PostFormArray("ids")) client, message := h.innerCodeSybClient() if message != "" { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) @@ -180,6 +184,20 @@ func (h *Handler) InnerCodeApply(c *gin.Context) { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message) } +func parseInnerCodeIDs(rawIDs []string) []int64 { + ids := make([]int64, 0, len(rawIDs)) + seen := make(map[int64]bool, len(rawIDs)) + for _, raw := range rawIDs { + id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || id <= 0 || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids +} + // InnerCodeRecheck 只重新读取未知结果,绝不再次发送删除或写入。 func (h *Handler) InnerCodeRecheck(c *gin.Context) { businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") diff --git a/admin/handler/web/inner_code_test.go b/admin/handler/web/inner_code_test.go new file mode 100644 index 0000000..bb71c91 --- /dev/null +++ b/admin/handler/web/inner_code_test.go @@ -0,0 +1,10 @@ +package web + +import "testing" + +func TestParseInnerCodeIDs_过滤无效并保持首次选择顺序(t *testing.T) { + got := parseInnerCodeIDs([]string{" 8 ", "bad", "0", "8", "3"}) + if len(got) != 2 || got[0] != 8 || got[1] != 3 { + t.Fatalf("解析结果不正确: %v", got) + } +} diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go index 6f9c167..e71943c 100644 --- a/admin/inner_code_template_test.go +++ b/admin/inner_code_template_test.go @@ -31,7 +31,9 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) { `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="档口入库码记录列表"`, + `data-need-checked="inner-code-match"`, `data-inner-code-id="{{.ID}}"`, + `inner-code-match{{end}}{{if .CanApply}} inner-code-apply`, + `{{if not .CanMatch}}disabled{{end}}`, `匹配已选`, `aria-label="档口入库码记录列表"`, } { if !strings.Contains(page, want) { t.Errorf("正式页面缺少 %s", want) @@ -47,4 +49,13 @@ func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) { if !strings.Contains(string(raw), `"/inner-codes": ["date", "status", "q", "page"]`) { t.Fatal("档口入库码页面切换模块后应保持稳定筛选和页码") } + for _, want := range []string{ + `appendSelectedIDs(matchForm, "inner-code-match")`, + `appendSelectedIDs(applyForm, "inner-code-apply")`, + `field.name = "ids"`, + } { + if !strings.Contains(string(raw), want) { + t.Errorf("档口入库码选中 ID 提交缺少 %s", want) + } + } } diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 1a6c2ee..0865988 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "sort" "strings" "github.com/go-sql-driver/mysql" @@ -116,32 +117,71 @@ func innerCodeImportWriteError(action string, err error) error { return fmt.Errorf("%s: %w", action, err) } -// ListInnerCodeRecordsForPlanning 返回某日允许重新规划的记录。 -func ListInnerCodeRecordsForPlanning(q Execer, businessDate string) ([]model.InnerCodeRecord, error) { - rows, err := q.Query(` - SELECT id,business_date,source_row,COALESCE(print_sequence,0),order_number, - COALESCE(shop_name,''),stall,spec_raw,spec_key,inner_code,source_duplicate_count, - status,COALESCE(result_message,''),created_by_user_id,created_at,updated_at +// ListInnerCodeRecordsForPlanning 返回某日选中且允许重新规划的记录。 +func ListInnerCodeRecordsForPlanning(q Execer, businessDate string, ids []int64) ([]model.InnerCodeRecord, error) { + if len(ids) == 0 { + return nil, nil + } + placeholders := make([]string, len(ids)) + args := make([]any, 0, len(ids)+1) + args = append(args, businessDate) + for index, id := range ids { + placeholders[index] = "?" + args = append(args, id) + } + rows, err := q.Query(`SELECT `+innerCodeListColumns+` FROM syb_inner_code_records - WHERE business_date=? AND status IN ('pending','ready','skipped','failed') - ORDER BY source_row,id`, businessDate) + WHERE business_date=? AND id IN (`+strings.Join(placeholders, ",")+`) + AND status IN ('pending','ready','skipped','failed') + ORDER BY source_row,id`, args...) if err != nil { - return nil, fmt.Errorf("查询待规划档口入库码失败: %w", err) + return nil, fmt.Errorf("查询选中档口入库码失败: %w", err) } defer rows.Close() result := make([]model.InnerCodeRecord, 0) for rows.Next() { - var row model.InnerCodeRecord - if err := rows.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence, - &row.OrderNumber, &row.ShopName, &row.Stall, &row.SpecRaw, &row.SpecKey, - &row.InnerCode, &row.SourceDuplicateCount, &row.Status, &row.ResultMessage, - &row.CreatedByUserID, &row.CreatedAt, &row.UpdatedAt); err != nil { - return nil, fmt.Errorf("读取待规划档口入库码失败: %w", err) + 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 nil, fmt.Errorf("遍历选中档口入库码失败: %w", err) + } + return result, nil +} + +// ListInnerCodePlanningContext 返回选中订单的全部同日记录,用于预留未选记录已经占用的远端明细。 +func ListInnerCodePlanningContext(q Execer, businessDate string, orderNumbers []string) ([]model.InnerCodeRecord, error) { + if len(orderNumbers) == 0 { + return nil, nil + } + placeholders := make([]string, len(orderNumbers)) + args := make([]any, 0, len(orderNumbers)+1) + args = append(args, businessDate) + for index, orderNumber := range orderNumbers { + placeholders[index] = "?" + args = append(args, orderNumber) + } + rows, err := q.Query(`SELECT `+innerCodeListColumns+` + FROM syb_inner_code_records + WHERE business_date=? AND order_number IN (`+strings.Join(placeholders, ",")+`) + ORDER BY source_row,id`, 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 } @@ -184,6 +224,9 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str return fmt.Errorf("开始保存档口入库码规划事务失败: %w", err) } defer tx.Rollback() + if err := lockAndValidateInnerCodePlanClaims(tx, plans); err != nil { + return err + } for _, plan := range plans { result, err := tx.Exec(` UPDATE syb_inner_code_records @@ -209,6 +252,80 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str return nil } +type innerCodePlanOrderKey struct { + BusinessDate string + OrderNumber string +} + +// lockAndValidateInnerCodePlanClaims 串行化同订单的部分匹配,并拒绝覆盖未选记录已有的明细占用。 +func lockAndValidateInnerCodePlanClaims(tx *sql.Tx, plans []model.InnerCodeRecord) error { + selected := make(map[int64]bool, len(plans)) + keySet := make(map[innerCodePlanOrderKey]bool) + for _, plan := range plans { + selected[plan.ID] = true + keySet[innerCodePlanOrderKey{BusinessDate: plan.BusinessDate, OrderNumber: plan.OrderNumber}] = true + } + keys := make([]innerCodePlanOrderKey, 0, len(keySet)) + for key := range keySet { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].BusinessDate == keys[j].BusinessDate { + return keys[i].OrderNumber < keys[j].OrderNumber + } + return keys[i].BusinessDate < keys[j].BusinessDate + }) + reserved := make(map[innerCodePlanOrderKey]map[int64]int64, len(keys)) + for _, key := range keys { + rows, err := tx.Query(`SELECT id,COALESCE(detail_id,0),status + FROM syb_inner_code_records + WHERE business_date=? AND order_number=? ORDER BY id FOR UPDATE`, key.BusinessDate, key.OrderNumber) + if err != nil { + return fmt.Errorf("锁定档口入库码订单匹配上下文失败: %w", err) + } + for rows.Next() { + var id, detailID int64 + var status model.InnerCodeStatus + if err := rows.Scan(&id, &detailID, &status); err != nil { + rows.Close() + return fmt.Errorf("读取档口入库码订单匹配上下文失败: %w", err) + } + if selected[id] || detailID <= 0 || !innerCodeStatusHoldsDetail(status) { + continue + } + if reserved[key] == nil { + reserved[key] = make(map[int64]int64) + } + reserved[key][detailID] = id + } + if err := rows.Err(); err != nil { + rows.Close() + return fmt.Errorf("遍历档口入库码订单匹配上下文失败: %w", err) + } + rows.Close() + } + for _, plan := range plans { + if plan.DetailID <= 0 || !innerCodeStatusHoldsDetail(plan.Status) { + continue + } + key := innerCodePlanOrderKey{BusinessDate: plan.BusinessDate, OrderNumber: plan.OrderNumber} + if ownerID := reserved[key][plan.DetailID]; ownerID > 0 { + return fmt.Errorf("档口入库码记录 %d 的顺运宝商品已被记录 %d 占用,请刷新后重新匹配", plan.ID, ownerID) + } + } + return nil +} + +func innerCodeStatusHoldsDetail(status model.InnerCodeStatus) bool { + switch status { + case model.InnerCodeReady, model.InnerCodeApplying, model.InnerCodeUpdated, + model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck: + return true + default: + return false + } +} + 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,''), diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go index 131440a..6d91c92 100644 --- a/admin/service/inner_code_match.go +++ b/admin/service/inner_code_match.go @@ -30,22 +30,35 @@ type InnerCodePlanResult struct { Failed int } -// PlanInnerCodeRecords 对某个业务日期的可重规划记录读取最新远端详情并原子保存计划。 +// PlanInnerCodeRecords 对某个业务日期选中的可重规划记录读取最新远端详情并原子保存计划。 // 本函数绝不调用删除或更新顺运宝接口。 -func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetailReader, businessDate string) (*InnerCodePlanResult, error) { +func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetailReader, businessDate string, selectedIDs []int64) (*InnerCodePlanResult, error) { if _, err := time.Parse("2006-01-02", businessDate); err != nil { return nil, fmt.Errorf("业务日期格式应为 YYYY-MM-DD") } - records, err := repository.ListInnerCodeRecordsForPlanning(db, businessDate) + selectedIDs = uniquePositiveInnerCodeIDs(selectedIDs) + if len(selectedIDs) == 0 { + return nil, fmt.Errorf("没有选中可匹配记录") + } + records, err := repository.ListInnerCodeRecordsForPlanning(db, businessDate, selectedIDs) if err != nil { return nil, err } - result := &InnerCodePlanResult{Total: len(records)} - if len(records) == 0 { - return result, nil + if len(records) != len(selectedIDs) { + return nil, fmt.Errorf("部分选中记录不存在、业务日期不一致或状态已变化,请刷新后重新勾选") } + result := &InnerCodePlanResult{Total: len(records)} orders := uniqueInnerCodeOrders(records) + contextRecords, err := repository.ListInnerCodePlanningContext(db, businessDate, orders) + if err != nil { + return nil, err + } + selectedSet := make(map[int64]bool, len(records)) + for _, record := range records { + selectedSet[record.ID] = true + } + reservedDetails := innerCodeReservedDetails(contextRecords, selectedSet) snapshots, err := repository.ListInnerCodeSybSnapshots(db, orders) if err != nil { return nil, err @@ -76,7 +89,7 @@ func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetai } } - plans := planInnerCodeRows(records, stockIDsByOrder, detailsByID) + plans := planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, reservedDetails) for _, plan := range plans { switch plan.Status { case model.InnerCodeReady: @@ -156,8 +169,42 @@ func uniqueInnerCodeStockIDs(records []model.InnerCodeRecord, byOrder map[string return result } +func innerCodeReservedDetails(records []model.InnerCodeRecord, selected map[int64]bool) map[string]map[int64]bool { + reserved := make(map[string]map[int64]bool) + for _, record := range records { + if selected[record.ID] || record.DetailID <= 0 || !innerCodeStatusReservesDetail(record.Status) { + continue + } + if reserved[record.OrderNumber] == nil { + reserved[record.OrderNumber] = make(map[int64]bool) + } + reserved[record.OrderNumber][record.DetailID] = true + } + return reserved +} + +func innerCodeStatusReservesDetail(status model.InnerCodeStatus) bool { + switch status { + case model.InnerCodeReady, model.InnerCodeApplying, model.InnerCodeUpdated, + model.InnerCodeAlreadyFilled, model.InnerCodeNeedsCheck: + return true + default: + return false + } +} + func planInnerCodeRows(records []model.InnerCodeRecord, stockIDsByOrder map[string][]int64, detailsByID map[int64]syb.StockDetail) []model.InnerCodeRecord { - used := make(map[string]map[int64]bool) + return planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, nil) +} + +func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOrder map[string][]int64, detailsByID map[int64]syb.StockDetail, reserved map[string]map[int64]bool) []model.InnerCodeRecord { + used := make(map[string]map[int64]bool, len(reserved)) + for orderNumber, detailIDs := range reserved { + used[orderNumber] = make(map[int64]bool, len(detailIDs)) + for detailID := range detailIDs { + used[orderNumber][detailID] = true + } + } plans := make([]model.InnerCodeRecord, 0, len(records)) for _, record := range records { plan := record @@ -189,16 +236,22 @@ func planInnerCodeRows(records []model.InnerCodeRecord, stockIDsByOrder map[stri used[record.OrderNumber] = make(map[int64]bool) } eligible := make([]syb.DetailItem, 0, len(stock.Details)) + availableBeforeReservation := 0 for _, item := range stock.Details { - if used[record.OrderNumber][item.ID] { - continue - } if innerCodeRawText(item.Raw["purchasePlatform"]) == "" && innerCodeRawText(item.Raw["purchaseCode"]) == "" { + availableBeforeReservation++ + if used[record.OrderNumber][item.ID] { + continue + } eligible = append(eligible, item) } } if len(eligible) == 0 { - plans = append(plans, innerCodeSkippedPlan(plan, "没有采购平台和采购单号都为空的顺运宝商品")) + message := "没有采购平台和采购单号都为空的顺运宝商品" + if availableBeforeReservation > 0 { + message = "符合条件的顺运宝商品已被同订单其他记录占用" + } + plans = append(plans, innerCodeSkippedPlan(plan, message)) continue } matches := matchInnerCodeItems(record.SpecRaw, record.Stall, eligible) diff --git a/admin/service/inner_code_match_test.go b/admin/service/inner_code_match_test.go index 5b2be3d..fd4641e 100644 --- a/admin/service/inner_code_match_test.go +++ b/admin/service/inner_code_match_test.go @@ -72,6 +72,48 @@ func TestPlanInnerCodeRows_同一远端明细不会占用两次(t *testing.T) { } } +func TestPlanInnerCodeRows_部分匹配保留未选记录的明细占用(t *testing.T) { + selected := model.InnerCodeRecord{ + ID: 1, BusinessDate: "2026-08-15", OrderNumber: "ORDER", + SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 1, + } + contextRecords := []model.InnerCodeRecord{ + selected, + {ID: 2, BusinessDate: "2026-08-15", OrderNumber: "ORDER", DetailID: 99, Status: model.InnerCodeReady}, + } + reserved := innerCodeReservedDetails(contextRecords, map[int64]bool{selected.ID: true}) + detail := syb.StockDetail{ID: 10, Details: []syb.DetailItem{innerCodeTestItem(99, "黑色,M", nil)}} + plans := planInnerCodeRowsWithReserved( + []model.InnerCodeRecord{selected}, + map[string][]int64{"ORDER": {10}}, + map[int64]syb.StockDetail{10: detail}, + reserved, + ) + if len(plans) != 1 || plans[0].Status != model.InnerCodeSkipped || + plans[0].ResultMessage != "符合条件的顺运宝商品已被同订单其他记录占用" { + t.Fatalf("未选记录的明细占用没有生效: %+v", plans) + } +} + +func TestInnerCodeReservedDetails_选中记录释放自身旧占用(t *testing.T) { + records := []model.InnerCodeRecord{ + {ID: 1, OrderNumber: "ORDER", DetailID: 99, Status: model.InnerCodeReady}, + {ID: 2, OrderNumber: "ORDER", DetailID: 100, Status: model.InnerCodeNeedsCheck}, + {ID: 3, OrderNumber: "ORDER", DetailID: 101, Status: model.InnerCodeSkipped}, + } + reserved := innerCodeReservedDetails(records, map[int64]bool{1: true}) + if reserved["ORDER"][99] || !reserved["ORDER"][100] || reserved["ORDER"][101] { + t.Fatalf("明细占用集合不正确: %+v", reserved) + } +} + +func TestUniquePositiveInnerCodeIDs_过滤无效和重复编号(t *testing.T) { + got := uniquePositiveInnerCodeIDs([]int64{3, 0, -1, 3, 2}) + if len(got) != 2 || got[0] != 3 || got[1] != 2 { + t.Fatalf("选中编号清理结果不正确: %v", got) + } +} + func TestPlanInnerCodeRows_远端已是目标码(t *testing.T) { record := model.InnerCodeRecord{ID: 1, OrderNumber: "ORDER", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 1} item := innerCodeTestItem(1, "黑色,M", map[string]any{"innerExpCode": "DK1"}) diff --git a/admin/service/inner_code_page.go b/admin/service/inner_code_page.go index 33f2821..ab846af 100644 --- a/admin/service/inner_code_page.go +++ b/admin/service/inner_code_page.go @@ -42,7 +42,8 @@ type InnerCodeRowView struct { StatusClass string ResultMessage string UpdatedAt string - CanSelect bool + CanMatch bool + CanApply bool HasRemoteOldCode bool CanRecheck bool } @@ -116,6 +117,11 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView { if statusText == "" { statusText = "未知" } + canMatch := record.Status == model.InnerCodePending || + record.Status == model.InnerCodeReady || + record.Status == model.InnerCodeSkipped || + record.Status == model.InnerCodeFailed + canApply := record.Status == model.InnerCodeReady return InnerCodeRowView{ ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber, Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw), @@ -123,8 +129,9 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView { 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 != "", + CanMatch: canMatch, + CanApply: canApply, + HasRemoteOldCode: canApply && record.RemoteInnerCode != "", CanRecheck: record.Status == model.InnerCodeNeedsCheck, } } diff --git a/admin/service/inner_code_page_test.go b/admin/service/inner_code_page_test.go index 696035a..691ab86 100644 --- a/admin/service/inner_code_page_test.go +++ b/admin/service/inner_code_page_test.go @@ -6,15 +6,20 @@ import ( "cmautobuy/admin/model" ) -func TestInnerCodeRowView_只有可回写记录可选择(t *testing.T) { +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) + wantMatch := status == model.InnerCodePending || status == model.InnerCodeReady || + status == model.InnerCodeSkipped || status == model.InnerCodeFailed + if view.CanMatch != wantMatch { + t.Errorf("status=%s CanMatch=%v", status, view.CanMatch) + } + if view.CanApply != (status == model.InnerCodeReady) { + t.Errorf("status=%s CanApply=%v", status, view.CanApply) } if view.CanRecheck != (status == model.InnerCodeNeedsCheck) { t.Errorf("status=%s CanRecheck=%v", status, view.CanRecheck) diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 2f4acdb..3eff68b 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -129,11 +129,15 @@ return actions.indexOf(action) !== -1; } - function checkedCount(action) { + function checkedBoxes(action) { return Array.prototype.filter.call( document.querySelectorAll("tbody input[type=checkbox]:checked:not(:disabled)"), function (box) { return supportsSelectAction(box, action); } - ).length; + ); + } + + function checkedCount(action) { + return checkedBoxes(action).length; } /* ── 需要勾选才能点的按钮 ────────────────── @@ -350,7 +354,8 @@ } /* 档口入库码页只有轻量表单联动:一个可见业务日期同步给三个普通表单, - 并在确认弹窗里显示当前选中数量。匹配和回写规则仍全部在服务端。 */ + 并按“匹配/回写”动作分别提交当前页符合条件的选中 ID。 + 匹配和回写规则仍全部在服务端。 */ function setupInnerCodePage() { var root = document.querySelector("[data-inner-code-page]"); if (!root) return; @@ -367,18 +372,33 @@ } 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) { + var matchSelected = checkedBoxes("inner-code-match"); + var applySelected = checkedBoxes("inner-code-apply"); + var replacements = applySelected.filter(function (box) { return box.getAttribute("data-inner-code-old-value") === "1"; }).length; + document.querySelectorAll("[data-inner-code-match-count]") + .forEach(function (node) { node.textContent = matchSelected.length; }); document.querySelectorAll("[data-inner-code-selected-count], [data-inner-code-confirm-count]") - .forEach(function (node) { node.textContent = selected.length; }); + .forEach(function (node) { node.textContent = applySelected.length; }); document.querySelectorAll("[data-inner-code-replace-count]") .forEach(function (node) { node.textContent = replacements; }); } + function appendSelectedIDs(form, action) { + form.querySelectorAll("[data-inner-code-generated-id]").forEach(function (field) { + field.remove(); + }); + checkedBoxes(action).forEach(function (box) { + var field = document.createElement("input"); + field.type = "hidden"; + field.name = "ids"; + field.value = box.getAttribute("data-inner-code-id") || box.value; + field.setAttribute("data-inner-code-generated-id", ""); + form.appendChild(field); + }); + } + if (dateInput) dateInput.addEventListener("change", syncDate); if (fileOpen && fileInput) fileOpen.addEventListener("click", function () { fileInput.click(); }); if (fileInput && fileName) { @@ -393,6 +413,7 @@ if (applyButton) applyButton.addEventListener("click", updateSelectionSummary); var matchForm = document.getElementById("inner-code-match-form"); if (matchForm) matchForm.addEventListener("submit", function () { + appendSelectedIDs(matchForm, "inner-code-match"); var button = document.querySelector('[form="inner-code-match-form"]'); if (button) { button.disabled = true; @@ -402,6 +423,7 @@ }); var applyForm = document.getElementById("inner-code-apply-form"); if (applyForm) applyForm.addEventListener("submit", function () { + appendSelectedIDs(applyForm, "inner-code-apply"); var button = document.querySelector('#inner-code-apply-modal button[type="submit"]'); if (button) { button.disabled = true; diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index 89023e7..6a33de6 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -37,7 +37,8 @@ - +