fix: 合并多件档口入库码回写 (#240)

This commit is contained in:
chengma
2026-08-15 14:27:31 +08:00
parent 5d72141f2f
commit 8e500606c5
10 changed files with 204 additions and 31 deletions
+2 -2
View File
@@ -108,8 +108,8 @@ func (h *Handler) InnerCodeImport(c *gin.Context) {
fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。") fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。")
return return
} }
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,文件内重复 %d 行。", message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,同业务键合并 %d 行。",
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.DuplicateRows) 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, message)
} }
+2 -1
View File
@@ -14,7 +14,8 @@ const (
InnerCodeNeedsCheck InnerCodeStatus = "needs_check" InnerCodeNeedsCheck InnerCodeStatus = "needs_check"
) )
// InnerCodeRecord 保存一张线下标签在导入、匹配和回写过程中的全部必要信息。 // InnerCodeRecord 保存一个顺运宝商品明细在导入、匹配和回写过程中的全部必要信息。
// 同一明细有多件时,InnerCode 是按 Excel 行顺序用英文逗号连接的多个单件码。
// Excel 只是载体,原文件不会进入数据库。 // Excel 只是载体,原文件不会进入数据库。
type InnerCodeRecord struct { type InnerCodeRecord struct {
ID int64 ID int64
+32
View File
@@ -50,6 +50,38 @@ const (
InnerCodeImportRestored InnerCodeImportOutcome = "restored" InnerCodeImportRestored InnerCodeImportOutcome = "restored"
) )
// InnerCodeCodeOwner 是导入事务中核对单件码归属所需的最小现有记录。
type InnerCodeCodeOwner struct {
ID int64
OrderNumber string
Stall string
SpecKey string
InnerCode string
}
// LockInnerCodeCodeOwnersByDate 锁定同日现有记录,供 service 拆分聚合值并防止单件码串单。
// 查询包含软删除记录,因为软删除记录仍保留业务唯一键并可能被重新导入恢复。
func LockInnerCodeCodeOwnersByDate(tx *sql.Tx, businessDate string) ([]InnerCodeCodeOwner, error) {
rows, err := tx.Query(`SELECT id,order_number,stall,spec_key,inner_code
FROM syb_inner_code_records WHERE business_date=? ORDER BY id FOR UPDATE`, businessDate)
if err != nil {
return nil, fmt.Errorf("锁定同日档口入库码记录失败: %w", err)
}
defer rows.Close()
owners := make([]InnerCodeCodeOwner, 0)
for rows.Next() {
var owner InnerCodeCodeOwner
if err := rows.Scan(&owner.ID, &owner.OrderNumber, &owner.Stall, &owner.SpecKey, &owner.InnerCode); err != nil {
return nil, fmt.Errorf("读取同日档口入库码记录失败: %w", err)
}
owners = append(owners, owner)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历同日档口入库码记录失败: %w", err)
}
return owners, nil
}
// UpsertInnerCodeImportRow 按已确认业务键写入一行。 // UpsertInnerCodeImportRow 按已确认业务键写入一行。
// 必须在事务中调用;先锁定业务键,避免另一个唯一键冲突时更新错行。 // 必须在事务中调用;先锁定业务键,避免另一个唯一键冲突时更新错行。
func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now string) (InnerCodeImportOutcome, error) { func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now string) (InnerCodeImportOutcome, error) {
+13
View File
@@ -17,6 +17,7 @@ type fakeInnerCodeWriter struct {
readCount int readCount int
deleteCount int deleteCount int
updateCount int updateCount int
updatedCode string
} }
func (f *fakeInnerCodeWriter) DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) { func (f *fakeInnerCodeWriter) DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) {
@@ -42,6 +43,7 @@ func (f *fakeInnerCodeWriter) DeleteInnerCode(_ context.Context, detailID int64)
func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error { func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error {
f.updateCount++ f.updateCount++
f.updatedCode = code
if f.updateErr != nil { if f.updateErr != nil {
return f.updateErr return f.updateErr
} }
@@ -53,6 +55,17 @@ func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID in
return nil return nil
} }
func TestApplyClaimedInnerCode_聚合码只写一次并按完整值复读(t *testing.T) {
record := innerCodeApplyTestRecord("")
record.InnerCode = "DK260815A160101,DK260815A160102"
writer := innerCodeApplyTestWriter(record, "")
outcome := applyClaimedInnerCode(context.Background(), writer, record)
if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 1 || writer.updatedCode != record.InnerCode ||
outcome.RemoteCode != record.InnerCode || writer.readCount != 2 {
t.Fatalf("outcome=%+v update=%d code=%q read=%d", outcome, writer.updateCount, writer.updatedCode, writer.readCount)
}
}
func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) { func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) {
record := innerCodeApplyTestRecord("") record := innerCodeApplyTestRecord("")
writer := innerCodeApplyTestWriter(record, "") writer := innerCodeApplyTestWriter(record, "")
+64 -9
View File
@@ -21,6 +21,7 @@ import (
const ( const (
MaxInnerCodeUploadBytes = 10 * 1024 * 1024 MaxInnerCodeUploadBytes = 10 * 1024 * 1024
MaxInnerCodeImportRows = 5000 MaxInnerCodeImportRows = 5000
MaxInnerCodeValueRunes = 128
innerCodeSheetName = "标签入库码映射" innerCodeSheetName = "标签入库码映射"
) )
@@ -46,7 +47,7 @@ type InnerCodeImportResult struct {
CreatedCount int CreatedCount int
UpdatedCount int UpdatedCount int
RestoredCount int RestoredCount int
DuplicateRows int MergedRows int
} }
// IsInvalidInnerCodeImport 判断错误是否需要用户修正上传文件。 // IsInvalidInnerCodeImport 判断错误是否需要用户修正上传文件。
@@ -123,6 +124,13 @@ func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string
return nil, fmt.Errorf("开始档口入库码导入事务失败: %w", err) return nil, fmt.Errorf("开始档口入库码导入事务失败: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
owners, err := repository.LockInnerCodeCodeOwnersByDate(tx, result.BusinessDate)
if err != nil {
return nil, err
}
if err := validateExistingInnerCodeOwners(result.BusinessDate, owners, rows); err != nil {
return nil, fmt.Errorf("%w:%v", ErrInvalidInnerCodeImport, err)
}
now := model.NowISO() now := model.NowISO()
for _, row := range rows { for _, row := range rows {
outcome, err := repository.UpsertInnerCodeImportRow(tx, row, now) outcome, err := repository.UpsertInnerCodeImportRow(tx, row, now)
@@ -156,6 +164,37 @@ type innerCodeParsedRow struct {
businessKey string businessKey string
} }
func innerCodeBusinessKey(businessDate, orderNumber, stall, specKey string) string {
return strings.Join([]string{businessDate, orderNumber, stall, specKey}, "\x00")
}
func validateExistingInnerCodeOwners(businessDate string, owners []repository.InnerCodeCodeOwner, rows []model.InnerCodeImportRow) error {
codeOwners := make(map[string]string)
for _, owner := range owners {
ownerKey := innerCodeBusinessKey(businessDate, owner.OrderNumber, owner.Stall, owner.SpecKey)
for _, code := range strings.Split(owner.InnerCode, ",") {
code = strings.TrimSpace(code)
if code == "" {
continue
}
if oldKey, exists := codeOwners[code]; exists && oldKey != ownerKey {
return fmt.Errorf("数据库中同日入库码 %q 已对应多条记录,请先人工核对", code)
}
codeOwners[code] = ownerKey
}
}
for _, row := range rows {
rowKey := innerCodeBusinessKey(row.BusinessDate, row.OrderNumber, row.Stall, row.SpecKey)
for _, code := range strings.Split(row.InnerCode, ",") {
if oldKey, exists := codeOwners[code]; exists && oldKey != rowKey {
return fmt.Errorf("Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录", row.SourceRow, code)
}
codeOwners[code] = rowKey
}
}
return nil
}
func parseInnerCodeWorkbook(book *excelize.File, originalFilename, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) { func parseInnerCodeWorkbook(book *excelize.File, originalFilename, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) {
found := false found := false
for _, name := range book.GetSheetList() { for _, name := range book.GetSheetList() {
@@ -242,7 +281,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
return nil, nil, err return nil, nil, err
} }
specKey := NormalizeInnerCodeSpecKey(specRaw) specKey := NormalizeInnerCodeSpecKey(specRaw)
key := strings.Join([]string{businessDate, orderNumber, stall, specKey}, "\x00") key := innerCodeBusinessKey(businessDate, orderNumber, stall, specKey)
parsed = append(parsed, innerCodeParsedRow{InnerCodeImportRow: model.InnerCodeImportRow{ parsed = append(parsed, innerCodeParsedRow{InnerCodeImportRow: model.InnerCodeImportRow{
BusinessDate: businessDate, SourceRow: rowNumber, PrintSequence: printSequence, BusinessDate: businessDate, SourceRow: rowNumber, PrintSequence: printSequence,
OrderNumber: orderNumber, ShopName: shop, Stall: stall, SpecRaw: specRaw, OrderNumber: orderNumber, ShopName: shop, Stall: stall, SpecRaw: specRaw,
@@ -278,15 +317,23 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
} }
seen[original.businessKey] = true seen[original.businessKey] = true
group := groups[original.businessKey] group := groups[original.businessKey]
for _, duplicate := range group[1:] {
if duplicate.InnerCode != original.InnerCode {
return nil, nil, fmt.Errorf("第 %d 行与第 %d 行业务键相同但入库码不同", original.SourceRow, duplicate.SourceRow)
}
}
row := original.InnerCodeImportRow row := original.InnerCodeImportRow
codes := make([]string, 0, len(group))
seenCodes := make(map[string]bool, len(group))
for _, member := range group {
if !seenCodes[member.InnerCode] {
seenCodes[member.InnerCode] = true
codes = append(codes, member.InnerCode)
}
}
row.InnerCode = strings.Join(codes, ",")
if utf8.RuneCountInString(row.InnerCode) > MaxInnerCodeValueRunes {
return nil, nil, fmt.Errorf("第 %d 行起的同业务键入库码连接后超过 %d 个字符(共 %d 个不同码)",
row.SourceRow, MaxInnerCodeValueRunes, len(codes))
}
row.SourceDuplicateCount = len(group) row.SourceDuplicateCount = len(group)
unique = append(unique, row) unique = append(unique, row)
result.DuplicateRows += len(group) - 1 result.MergedRows += len(group) - 1
} }
return result, unique, nil return result, unique, nil
} }
@@ -377,12 +424,20 @@ func innerCodeCell(columns []string, index int) string {
} }
func validateInnerCodeFields(row int, order, shop, stall, spec, code string) error { func validateInnerCodeFields(row int, order, shop, stall, spec, code string) error {
if strings.Contains(code, ",") {
return fmt.Errorf("第 %d 行内部档口入库码不能包含英文逗号", row)
}
for _, character := range code {
if character < 32 || character == 127 {
return fmt.Errorf("第 %d 行内部档口入库码不能包含控制字符", row)
}
}
for _, field := range []struct { for _, field := range []struct {
name, value string name, value string
max int max int
}{ }{
{"Shopee订单编号", order, 64}, {"店铺名称", shop, 191}, {"档口及货号", stall, 191}, {"Shopee订单编号", order, 64}, {"店铺名称", shop, 191}, {"档口及货号", stall, 191},
{"规格", spec, 500}, {"内部档口入库码", code, 128}, {"规格", spec, 500}, {"内部档口入库码", code, MaxInnerCodeValueRunes},
} { } {
if utf8.RuneCountInString(field.value) > field.max { if utf8.RuneCountInString(field.value) > field.max {
return fmt.Errorf("第 %d 行%s超过 %d 个字符", row, field.name, field.max) return fmt.Errorf("第 %d 行%s超过 %d 个字符", row, field.name, field.max)
+79 -7
View File
@@ -6,6 +6,9 @@ import (
"testing" "testing"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
) )
func TestNormalizeInnerCodeSpecKey(t *testing.T) { func TestNormalizeInnerCodeSpecKey(t *testing.T) {
@@ -42,7 +45,7 @@ func TestParseInnerCodeWorkbook_表头前置行与重复业务组(t *testing.T)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.TotalRows != 3 || result.DuplicateRows != 1 || len(imported) != 2 { if result.TotalRows != 3 || result.MergedRows != 1 || len(imported) != 2 {
t.Fatalf("统计不正确: result=%+v rows=%d", result, len(imported)) t.Fatalf("统计不正确: result=%+v rows=%d", result, len(imported))
} }
first := imported[0] first := imported[0]
@@ -55,6 +58,9 @@ func TestParseInnerCodeWorkbook_表头前置行与重复业务组(t *testing.T)
if first.SourceRow != 3 || first.PrintSequence != 1 || first.ShopName != "shop-a" { if first.SourceRow != 3 || first.PrintSequence != 1 || first.ShopName != "shop-a" {
t.Fatalf("首行来源字段不正确: %+v", first) t.Fatalf("首行来源字段不正确: %+v", first)
} }
if first.InnerCode != "DK001" {
t.Fatalf("相同入库码重复行不应重复拼接: %+v", first)
}
} }
func TestParseInnerCodeWorkbook_同日入库码指向不同业务键时报错(t *testing.T) { func TestParseInnerCodeWorkbook_同日入库码指向不同业务键时报错(t *testing.T) {
@@ -74,20 +80,71 @@ func TestParseInnerCodeWorkbook_同日入库码指向不同业务键时报错(t
} }
} }
func TestParseInnerCodeWorkbook_业务键相同但入库码不同时报错(t *testing.T) { func TestParseInnerCodeWorkbook_同业务键多个单件码按源行聚合(t *testing.T) {
book := excelize.NewFile() book := excelize.NewFile()
defaultSheet := book.GetSheetList()[0] defaultSheet := book.GetSheetList()[0]
if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil { if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil {
t.Fatal(err) t.Fatal(err)
} }
setInnerCodeWorkbookRows(t, book, [][]string{ setInnerCodeWorkbookRows(t, book, [][]string{
{"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号", "生成日期"}, {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号", "生成日期", "商品件序"},
{"DK001", "ORDER-1", "黑色,M", "A#1", "2026-08-15"}, {"DK260815A160101", "26081394YBWB17", "黑色【原版好質量】,6XL建議127.5-150.0公斤", "山湖-易興网购#0035", "2026-08-15", "01"},
{"DK002", "ORDER-1", "黑色,M", "A#1", "2026-08-15"}, {"DK260815A160102", "26081394YBWB17", "黑色【原版好質量】,6XL建議127.5-150.0公斤", "山湖-易興网购#0035", "2026-08-15", "02"},
}) })
result, rows, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1")
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || result.TotalRows != 2 || result.MergedRows != 1 {
t.Fatalf("聚合统计不正确 result=%+v rows=%+v", result, rows)
}
if rows[0].InnerCode != "DK260815A160101,DK260815A160102" || rows[0].SourceDuplicateCount != 2 {
t.Fatalf("多个单件码聚合不正确: %+v", rows[0])
}
}
func TestParseInnerCodeWorkbook_三个单件码稳定去重且超长时报错(t *testing.T) {
book := newInnerCodeCodeGroupTestBook(t, []string{"DK003", "DK001", "DK003", "DK002"})
result, rows, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1")
book.Close()
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].InnerCode != "DK003,DK001,DK002" || rows[0].SourceDuplicateCount != 4 || result.MergedRows != 3 {
t.Fatalf("稳定去重结果不正确 result=%+v rows=%+v", result, rows)
}
longBook := newInnerCodeCodeGroupTestBook(t, []string{strings.Repeat("A", 70), strings.Repeat("B", 70)})
defer longBook.Close()
_, _, err = parseInnerCodeWorkbook(longBook, "labels.xlsx", "user-1")
if err == nil || !strings.Contains(err.Error(), "连接后超过 128 个字符") {
t.Fatalf("期望聚合值超长错误,实际 %v", err)
}
}
func TestParseInnerCodeWorkbook_单件码不能占用聚合分隔符(t *testing.T) {
book := newInnerCodeCodeGroupTestBook(t, []string{"DK001,DK002"})
defer book.Close()
_, _, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1") _, _, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1")
if err == nil || !strings.Contains(err.Error(), "业务键相同") { if err == nil || !strings.Contains(err.Error(), "不能包含英文逗号") {
t.Fatalf("期望业务键冲突错误,实际 %v", err) t.Fatalf("期望逗号分隔符错误,实际 %v", err)
}
}
func TestValidateExistingInnerCodeOwners_聚合中的单件码不能串到其他业务键(t *testing.T) {
owners := []repository.InnerCodeCodeOwner{{
ID: 1, OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,M", InnerCode: "DK001,DK002",
}}
rows := []model.InnerCodeImportRow{{
BusinessDate: "2026-08-15", SourceRow: 8, OrderNumber: "ORDER-2", Stall: "B#2",
SpecKey: "白色,L", InnerCode: "DK002,DK003",
}}
if err := validateExistingInnerCodeOwners("2026-08-15", owners, rows); err == nil || !strings.Contains(err.Error(), "已属于另一条记录") {
t.Fatalf("期望组成码归属冲突,实际 %v", err)
}
rows[0].OrderNumber, rows[0].Stall, rows[0].SpecKey = "ORDER-1", "A#1", "黑色,M"
if err := validateExistingInnerCodeOwners("2026-08-15", owners, rows); err != nil {
t.Fatalf("同一业务键更新聚合值应允许,实际 %v", err)
} }
} }
@@ -195,6 +252,21 @@ func newInnerCodeDateTestBook(t *testing.T, businessDate string) *excelize.File
return book return book
} }
func newInnerCodeCodeGroupTestBook(t *testing.T, codes []string) *excelize.File {
t.Helper()
book := excelize.NewFile()
defaultSheet := book.GetSheetList()[0]
if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil {
t.Fatal(err)
}
rows := [][]string{{"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号", "生成日期"}}
for _, code := range codes {
rows = append(rows, []string{code, "ORDER-1", "黑色,M", "A#1", "2026-08-15"})
}
setInnerCodeWorkbookRows(t, book, rows)
return book
}
func setInnerCodeWorkbookRows(t *testing.T, book *excelize.File, rows [][]string) { func setInnerCodeWorkbookRows(t *testing.T, book *excelize.File, rows [][]string) {
t.Helper() t.Helper()
for rowIndex, row := range rows { for rowIndex, row := range rows {
-4
View File
@@ -215,10 +215,6 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
plans = append(plans, innerCodeSkippedPlan(plan, "Excel 清洗后规格为空,跳过")) plans = append(plans, innerCodeSkippedPlan(plan, "Excel 清洗后规格为空,跳过"))
continue continue
} }
if record.SourceDuplicateCount > 1 {
plans = append(plans, innerCodeSkippedPlan(plan, "同订单同规格同档口在 Excel 中出现多次,跳过"))
continue
}
stockIDs := stockIDsByOrder[record.OrderNumber] stockIDs := stockIDsByOrder[record.OrderNumber]
if len(stockIDs) == 0 { if len(stockIDs) == 0 {
plan.Status = model.InnerCodeFailed plan.Status = model.InnerCodeFailed
+3 -3
View File
@@ -36,7 +36,7 @@ func TestPlanInnerCodeRows_精确与标准化规格均受档口约束(t *testing
} }
} }
func TestPlanInnerCodeRows_重复空规格采购占用和歧义全部阻断(t *testing.T) { func TestPlanInnerCodeRows_聚合码放行且其他异常继续阻断(t *testing.T) {
records := []model.InnerCodeRecord{ records := []model.InnerCodeRecord{
{ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 2}, {ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 2},
{ID: 2, OrderNumber: "EMPTY", SpecRaw: "", InnerCode: "DK2", SourceDuplicateCount: 1}, {ID: 2, OrderNumber: "EMPTY", SpecRaw: "", InnerCode: "DK2", SourceDuplicateCount: 1},
@@ -52,9 +52,9 @@ func TestPlanInnerCodeRows_重复空规格采购占用和歧义全部阻断(t *t
4: {ID: 4, Details: []syb.DetailItem{innerCodeTestItem(4, "黑色,M", nil), innerCodeTestItem(5, "黑色,M", nil)}}, 4: {ID: 4, Details: []syb.DetailItem{innerCodeTestItem(4, "黑色,M", nil), innerCodeTestItem(5, "黑色,M", nil)}},
} }
plans := planInnerCodeRows(records, stocks, details) plans := planInnerCodeRows(records, stocks, details)
wants := []model.InnerCodeStatus{model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeFailed} wants := []model.InnerCodeStatus{model.InnerCodeReady, model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeSkipped, model.InnerCodeFailed}
for index, want := range wants { for index, want := range wants {
if plans[index].Status != want || plans[index].Status == model.InnerCodeReady { if plans[index].Status != want {
t.Errorf("第 %d 条 status=%s message=%s,期望 %s", index, plans[index].Status, plans[index].ResultMessage, want) t.Errorf("第 %d 条 status=%s message=%s,期望 %s", index, plans[index].Status, plans[index].ResultMessage, want)
} }
} }
+4 -3
View File
@@ -431,7 +431,8 @@ func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
} }
} }
func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testing.T) { func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次(t *testing.T) {
targetCode := "DK260815A160101,DK260815A160102"
var requests atomic.Int32 var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1) requests.Add(1)
@@ -443,7 +444,7 @@ func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testin
} }
case "/am/stock/detail/updateDetailCode": case "/am/stock/detail/updateDetailCode":
query := r.URL.Query() query := r.URL.Query()
if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != "DK-001" { if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != targetCode {
t.Errorf("update query=%v", query) t.Errorf("update query=%v", query)
} }
default: default:
@@ -459,7 +460,7 @@ func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testin
if err := client.DeleteInnerCode(context.Background(), 22); err != nil { if err := client.DeleteInnerCode(context.Background(), 22); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := client.UpdateDetailCode(context.Background(), 11, 22, " DK-001 "); err != nil { if err := client.UpdateDetailCode(context.Background(), 11, 22, " "+targetCode+" "); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if requests.Load() != 2 { if requests.Load() != 2 {
+6 -3
View File
@@ -1027,10 +1027,13 @@ CREATE UNIQUE INDEX idx_client_assignment_current
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统 档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
不保存原文件、文件哈希,不再拆批次表或尝试记录表。 不保存原文件、文件哈希,不再拆批次表或尝试记录表。
- 业务键为 `(business_date, order_number, stall, spec_key)`;同一业务日期的 - 业务键为 `(business_date, order_number, stall, spec_key)`。一个顺运宝商品明细数量大于 1
`inner_code` 另设唯一约束,防止一张标签被分配给两个商品。 时,Excel 按件生成的不同入库码按源行顺序使用英文逗号连接,作为该业务记录唯一的
`inner_code` 目标值;单个码不得包含逗号,聚合值不得超过 128 个字符。
- 同一业务日期的聚合 `inner_code` 另设唯一约束;导入事务还会拆分并核对同日现有聚合值,
防止其中任一单件码被分配给另一个业务键。
- `spec_raw` 永远保留 Excel 原文,`spec_key` 只用于确定性匹配; - `spec_raw` 永远保留 Excel 原文,`spec_key` 只用于确定性匹配;
`source_duplicate_count > 1` 表示同一文件存在重复业务组,匹配时整组跳过。 `source_duplicate_count` 保存合并前的 Excel 源行数,只用于展示和审计,不阻止确定性匹配。
- `stock_id/detail_id` 和 `syb_*`、`purchase_*`、`remote_inner_code` 保存最近一次远端 - `stock_id/detail_id` 和 `syb_*`、`purchase_*`、`remote_inner_code` 保存最近一次远端
规划快照,不替代 `syb_orders`,也不修改采购数据。 规划快照,不替代 `syb_orders`,也不修改采购数据。
- 状态固定为 `pending/ready/applying/updated/already_filled/skipped/failed/needs_check`。 - 状态固定为 `pending/ready/applying/updated/already_filled/skipped/failed/needs_check`。