feat: 支持勾选部分档口入库码匹配 (#235)

This commit is contained in:
chengma
2026-08-15 09:41:45 +08:00
parent 05b59974e8
commit 63f8407780
10 changed files with 342 additions and 55 deletions
+25 -7
View File
@@ -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, "业务日期无效,没有执行匹配。") h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1, "业务日期无效,没有执行匹配。")
return 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() cfg, err := config.Load()
if err != nil { if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝配置不可用,没有执行匹配。") 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) h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
return 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 { if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "匹配失败:"+err.Error()) h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "匹配失败:"+err.Error())
return return
@@ -159,12 +168,7 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
func (h *Handler) InnerCodeApply(c *gin.Context) { func (h *Handler) InnerCodeApply(c *gin.Context) {
businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q")
pageNumber := service.ParsePage(c.PostForm("page")) pageNumber := service.ParsePage(c.PostForm("page"))
ids := make([]int64, 0) ids := parseInnerCodeIDs(c.PostFormArray("ids"))
for _, raw := range c.PostFormArray("ids") {
if id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); err == nil && id > 0 {
ids = append(ids, id)
}
}
client, message := h.innerCodeSybClient() client, message := h.innerCodeSybClient()
if message != "" { if message != "" {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, 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) 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 只重新读取未知结果,绝不再次发送删除或写入。 // InnerCodeRecheck 只重新读取未知结果,绝不再次发送删除或写入。
func (h *Handler) InnerCodeRecheck(c *gin.Context) { func (h *Handler) InnerCodeRecheck(c *gin.Context) {
businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q")
+10
View File
@@ -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)
}
}
+12 -1
View File
@@ -31,7 +31,9 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
`action="/inner-codes/import"`, `action="/inner-codes/match"`, `action="/inner-codes/import"`, `action="/inner-codes/match"`,
`action="/inner-codes/apply"`, `action="/inner-codes/recheck"`, `action="/inner-codes/apply"`, `action="/inner-codes/recheck"`,
`name="csrf_token"`, `data-check-all`, `data-inner-code-apply-open`, `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) { if !strings.Contains(page, want) {
t.Errorf("正式页面缺少 %s", 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"]`) { if !strings.Contains(string(raw), `"/inner-codes": ["date", "status", "q", "page"]`) {
t.Fatal("档口入库码页面切换模块后应保持稳定筛选和页码") 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)
}
}
} }
+133 -16
View File
@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
@@ -116,32 +117,71 @@ func innerCodeImportWriteError(action string, err error) error {
return fmt.Errorf("%s: %w", action, err) return fmt.Errorf("%s: %w", action, err)
} }
// ListInnerCodeRecordsForPlanning 返回某日允许重新规划的记录。 // ListInnerCodeRecordsForPlanning 返回某日选中且允许重新规划的记录。
func ListInnerCodeRecordsForPlanning(q Execer, businessDate string) ([]model.InnerCodeRecord, error) { func ListInnerCodeRecordsForPlanning(q Execer, businessDate string, ids []int64) ([]model.InnerCodeRecord, error) {
rows, err := q.Query(` if len(ids) == 0 {
SELECT id,business_date,source_row,COALESCE(print_sequence,0),order_number, return nil, nil
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 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 FROM syb_inner_code_records
WHERE business_date=? AND status IN ('pending','ready','skipped','failed') WHERE business_date=? AND id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY source_row,id`, businessDate) AND status IN ('pending','ready','skipped','failed')
ORDER BY source_row,id`, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("查询待规划档口入库码失败: %w", err) return nil, fmt.Errorf("查询选中档口入库码失败: %w", err)
} }
defer rows.Close() defer rows.Close()
result := make([]model.InnerCodeRecord, 0) result := make([]model.InnerCodeRecord, 0)
for rows.Next() { for rows.Next() {
var row model.InnerCodeRecord row, err := scanInnerCodeRecord(rows)
if err := rows.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence, if err != nil {
&row.OrderNumber, &row.ShopName, &row.Stall, &row.SpecRaw, &row.SpecKey, return nil, err
&row.InnerCode, &row.SourceDuplicateCount, &row.Status, &row.ResultMessage,
&row.CreatedByUserID, &row.CreatedAt, &row.UpdatedAt); err != nil {
return nil, fmt.Errorf("读取待规划档口入库码失败: %w", err)
} }
result = append(result, row) result = append(result, row)
} }
if err := rows.Err(); err != nil { 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 return result, nil
} }
@@ -184,6 +224,9 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
return fmt.Errorf("开始保存档口入库码规划事务失败: %w", err) return fmt.Errorf("开始保存档口入库码规划事务失败: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
if err := lockAndValidateInnerCodePlanClaims(tx, plans); err != nil {
return err
}
for _, plan := range plans { for _, plan := range plans {
result, err := tx.Exec(` result, err := tx.Exec(`
UPDATE syb_inner_code_records UPDATE syb_inner_code_records
@@ -209,6 +252,80 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str
return nil 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, 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(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(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
+65 -12
View File
@@ -30,22 +30,35 @@ type InnerCodePlanResult struct {
Failed int 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 { if _, err := time.Parse("2006-01-02", businessDate); err != nil {
return nil, fmt.Errorf("业务日期格式应为 YYYY-MM-DD") 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 { if err != nil {
return nil, err return nil, err
} }
result := &InnerCodePlanResult{Total: len(records)} if len(records) != len(selectedIDs) {
if len(records) == 0 { return nil, fmt.Errorf("部分选中记录不存在、业务日期不一致或状态已变化,请刷新后重新勾选")
return result, nil
} }
result := &InnerCodePlanResult{Total: len(records)}
orders := uniqueInnerCodeOrders(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) snapshots, err := repository.ListInnerCodeSybSnapshots(db, orders)
if err != nil { if err != nil {
return nil, err 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 { for _, plan := range plans {
switch plan.Status { switch plan.Status {
case model.InnerCodeReady: case model.InnerCodeReady:
@@ -156,8 +169,42 @@ func uniqueInnerCodeStockIDs(records []model.InnerCodeRecord, byOrder map[string
return result 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 { 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)) plans := make([]model.InnerCodeRecord, 0, len(records))
for _, record := range records { for _, record := range records {
plan := record plan := record
@@ -189,16 +236,22 @@ func planInnerCodeRows(records []model.InnerCodeRecord, stockIDsByOrder map[stri
used[record.OrderNumber] = make(map[int64]bool) used[record.OrderNumber] = make(map[int64]bool)
} }
eligible := make([]syb.DetailItem, 0, len(stock.Details)) eligible := make([]syb.DetailItem, 0, len(stock.Details))
availableBeforeReservation := 0
for _, item := range stock.Details { for _, item := range stock.Details {
if used[record.OrderNumber][item.ID] {
continue
}
if innerCodeRawText(item.Raw["purchasePlatform"]) == "" && innerCodeRawText(item.Raw["purchaseCode"]) == "" { if innerCodeRawText(item.Raw["purchasePlatform"]) == "" && innerCodeRawText(item.Raw["purchaseCode"]) == "" {
availableBeforeReservation++
if used[record.OrderNumber][item.ID] {
continue
}
eligible = append(eligible, item) eligible = append(eligible, item)
} }
} }
if len(eligible) == 0 { if len(eligible) == 0 {
plans = append(plans, innerCodeSkippedPlan(plan, "没有采购平台和采购单号都为空的顺运宝商品")) message := "没有采购平台和采购单号都为空的顺运宝商品"
if availableBeforeReservation > 0 {
message = "符合条件的顺运宝商品已被同订单其他记录占用"
}
plans = append(plans, innerCodeSkippedPlan(plan, message))
continue continue
} }
matches := matchInnerCodeItems(record.SpecRaw, record.Stall, eligible) matches := matchInnerCodeItems(record.SpecRaw, record.Stall, eligible)
+42
View File
@@ -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) { func TestPlanInnerCodeRows_远端已是目标码(t *testing.T) {
record := model.InnerCodeRecord{ID: 1, OrderNumber: "ORDER", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 1} record := model.InnerCodeRecord{ID: 1, OrderNumber: "ORDER", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 1}
item := innerCodeTestItem(1, "黑色,M", map[string]any{"innerExpCode": "DK1"}) item := innerCodeTestItem(1, "黑色,M", map[string]any{"innerExpCode": "DK1"})
+10 -3
View File
@@ -42,7 +42,8 @@ type InnerCodeRowView struct {
StatusClass string StatusClass string
ResultMessage string ResultMessage string
UpdatedAt string UpdatedAt string
CanSelect bool CanMatch bool
CanApply bool
HasRemoteOldCode bool HasRemoteOldCode bool
CanRecheck bool CanRecheck bool
} }
@@ -116,6 +117,11 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView {
if statusText == "" { if statusText == "" {
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{ return InnerCodeRowView{
ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber, ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber,
Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw), 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), RemoteInnerCode: displayInnerCodeValue(record.RemoteInnerCode), Status: string(record.Status),
StatusText: statusText, StatusClass: "inner-code-status-" + string(record.Status), StatusText: statusText, StatusClass: "inner-code-status-" + string(record.Status),
ResultMessage: displayInnerCodeValue(record.ResultMessage), UpdatedAt: formatLocalTime(record.UpdatedAt), ResultMessage: displayInnerCodeValue(record.ResultMessage), UpdatedAt: formatLocalTime(record.UpdatedAt),
CanSelect: record.Status == model.InnerCodeReady, CanMatch: canMatch,
HasRemoteOldCode: record.Status == model.InnerCodeReady && record.RemoteInnerCode != "", CanApply: canApply,
HasRemoteOldCode: canApply && record.RemoteInnerCode != "",
CanRecheck: record.Status == model.InnerCodeNeedsCheck, CanRecheck: record.Status == model.InnerCodeNeedsCheck,
} }
} }
+8 -3
View File
@@ -6,15 +6,20 @@ import (
"cmautobuy/admin/model" "cmautobuy/admin/model"
) )
func TestInnerCodeRowView_只有可回写记录可选择(t *testing.T) { func TestInnerCodeRowView_按状态区分匹配与回写能力(t *testing.T) {
for _, status := range []model.InnerCodeStatus{ for _, status := range []model.InnerCodeStatus{
model.InnerCodePending, model.InnerCodeReady, model.InnerCodeApplying, model.InnerCodePending, model.InnerCodeReady, model.InnerCodeApplying,
model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeSkipped, model.InnerCodeUpdated, model.InnerCodeAlreadyFilled, model.InnerCodeSkipped,
model.InnerCodeFailed, model.InnerCodeNeedsCheck, model.InnerCodeFailed, model.InnerCodeNeedsCheck,
} { } {
view := innerCodeRowView(model.InnerCodeRecord{Status: status, RemoteInnerCode: "OLD"}) view := innerCodeRowView(model.InnerCodeRecord{Status: status, RemoteInnerCode: "OLD"})
if view.CanSelect != (status == model.InnerCodeReady) { wantMatch := status == model.InnerCodePending || status == model.InnerCodeReady ||
t.Errorf("status=%s CanSelect=%v", status, view.CanSelect) 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) { if view.CanRecheck != (status == model.InnerCodeNeedsCheck) {
t.Errorf("status=%s CanRecheck=%v", status, view.CanRecheck) t.Errorf("status=%s CanRecheck=%v", status, view.CanRecheck)
+30 -8
View File
@@ -129,11 +129,15 @@
return actions.indexOf(action) !== -1; return actions.indexOf(action) !== -1;
} }
function checkedCount(action) { function checkedBoxes(action) {
return Array.prototype.filter.call( return Array.prototype.filter.call(
document.querySelectorAll("tbody input[type=checkbox]:checked:not(:disabled)"), document.querySelectorAll("tbody input[type=checkbox]:checked:not(:disabled)"),
function (box) { return supportsSelectAction(box, action); } function (box) { return supportsSelectAction(box, action); }
).length; );
}
function checkedCount(action) {
return checkedBoxes(action).length;
} }
/* ── 需要勾选才能点的按钮 ────────────────── /* ── 需要勾选才能点的按钮 ──────────────────
@@ -350,7 +354,8 @@
} }
/* 档口入库码页只有轻量表单联动:一个可见业务日期同步给三个普通表单, /* 档口入库码页只有轻量表单联动:一个可见业务日期同步给三个普通表单,
并在确认弹窗里显示当前选中数量。匹配和回写规则仍全部在服务端。 */ 并按“匹配/回写”动作分别提交当前页符合条件的选中 ID。
匹配和回写规则仍全部在服务端。 */
function setupInnerCodePage() { function setupInnerCodePage() {
var root = document.querySelector("[data-inner-code-page]"); var root = document.querySelector("[data-inner-code-page]");
if (!root) return; if (!root) return;
@@ -367,18 +372,33 @@
} }
function updateSelectionSummary() { function updateSelectionSummary() {
var selected = Array.prototype.slice.call(document.querySelectorAll( var matchSelected = checkedBoxes("inner-code-match");
'input[name="ids"][form="inner-code-apply-form"]:checked:not(:disabled)' var applySelected = checkedBoxes("inner-code-apply");
)); var replacements = applySelected.filter(function (box) {
var replacements = selected.filter(function (box) {
return box.getAttribute("data-inner-code-old-value") === "1"; return box.getAttribute("data-inner-code-old-value") === "1";
}).length; }).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]") 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]") document.querySelectorAll("[data-inner-code-replace-count]")
.forEach(function (node) { node.textContent = replacements; }); .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 (dateInput) dateInput.addEventListener("change", syncDate);
if (fileOpen && fileInput) fileOpen.addEventListener("click", function () { fileInput.click(); }); if (fileOpen && fileInput) fileOpen.addEventListener("click", function () { fileInput.click(); });
if (fileInput && fileName) { if (fileInput && fileName) {
@@ -393,6 +413,7 @@
if (applyButton) applyButton.addEventListener("click", updateSelectionSummary); if (applyButton) applyButton.addEventListener("click", updateSelectionSummary);
var matchForm = document.getElementById("inner-code-match-form"); var matchForm = document.getElementById("inner-code-match-form");
if (matchForm) matchForm.addEventListener("submit", function () { if (matchForm) matchForm.addEventListener("submit", function () {
appendSelectedIDs(matchForm, "inner-code-match");
var button = document.querySelector('[form="inner-code-match-form"]'); var button = document.querySelector('[form="inner-code-match-form"]');
if (button) { if (button) {
button.disabled = true; button.disabled = true;
@@ -402,6 +423,7 @@
}); });
var applyForm = document.getElementById("inner-code-apply-form"); var applyForm = document.getElementById("inner-code-apply-form");
if (applyForm) applyForm.addEventListener("submit", function () { if (applyForm) applyForm.addEventListener("submit", function () {
appendSelectedIDs(applyForm, "inner-code-apply");
var button = document.querySelector('#inner-code-apply-modal button[type="submit"]'); var button = document.querySelector('#inner-code-apply-modal button[type="submit"]');
if (button) { if (button) {
button.disabled = true; button.disabled = true;
+7 -5
View File
@@ -37,7 +37,8 @@
<input type="hidden" name="q" value="{{.Keyword}}"> <input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
</form> </form>
<button type="submit" class="primary" form="inner-code-match-form">匹配顺运宝商品</button> <button type="submit" class="primary" form="inner-code-match-form"
data-need-checked="inner-code-match" disabled>匹配已选 <span data-inner-code-match-count>0</span> 条</button>
<form id="inner-code-apply-form" method="post" action="/inner-codes/apply" hidden> <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="csrf_token" value="{{.CSRFToken}}">
@@ -56,7 +57,7 @@
<table class="inner-code-table"> <table class="inner-code-table">
<thead> <thead>
<tr> <tr>
<th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页可回写记录"></th> <th class="col-check"><input type="checkbox" data-check-all aria-label="全选当前页可匹配记录"></th>
<th>业务日期</th> <th>业务日期</th>
<th>订单号</th> <th>订单号</th>
<th>档口及货号</th> <th>档口及货号</th>
@@ -74,9 +75,10 @@
{{range .Rows}} {{range .Rows}}
<tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}> <tr {{if or (eq .Status "failed") (eq .Status "needs_check")}}class="row-warn"{{end}}>
<td class="col-check"> <td class="col-check">
<input type="checkbox" name="ids" value="{{.ID}}" form="inner-code-apply-form" <input type="checkbox" value="{{.ID}}" data-inner-code-id="{{.ID}}"
data-select-action="inner-code-apply" data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}" data-select-action="{{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply{{end}}"
aria-label="选择订单 {{.OrderNumber}}" {{if not .CanSelect}}disabled{{end}}> data-inner-code-old-value="{{if .HasRemoteOldCode}}1{{else}}0{{end}}"
aria-label="选择订单 {{.OrderNumber}}" {{if not .CanMatch}}disabled{{end}}>
</td> </td>
<td>{{.BusinessDate}}</td> <td>{{.BusinessDate}}</td>
<td>{{.OrderNumber}}</td> <td>{{.OrderNumber}}</td>