From b8f49fe4bbf0ac3fd9a400adb92f82ecb68756ce Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 18 Aug 2026 15:18:35 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=9A=E4=BB=B6=E6=A1=A3=E5=8F=A3?= =?UTF-8?q?=E5=85=A5=E5=BA=93=E7=A0=81=E9=80=90=E4=BB=B6=E5=9B=9E=E5=86=99?= =?UTF-8?q?=20(#250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/model/inner_code.go | 1 + admin/repository/inner_code.go | 30 ++- admin/repository/mysql_db.go | 45 +++- admin/service/inner_code_apply.go | 306 ++++++++++++++++++++----- admin/service/inner_code_apply_test.go | 133 +++++++++-- admin/service/inner_code_match.go | 19 +- admin/service/inner_code_match_test.go | 8 +- admin/service/inner_code_page.go | 8 + admin/static/js/app.js | 10 + admin/syb/client.go | 28 ++- admin/syb/client_test.go | 28 ++- admin/templates/inner_code/list.html | 11 +- docs/admin/03-data-model.md | 8 +- docs/admin/08-顺运宝接口.md | 23 +- 14 files changed, 547 insertions(+), 111 deletions(-) diff --git a/admin/model/inner_code.go b/admin/model/inner_code.go index 7513139..f357567 100644 --- a/admin/model/inner_code.go +++ b/admin/model/inner_code.go @@ -40,6 +40,7 @@ type InnerCodeRecord struct { PurchasePlatform string PurchaseCode string RemoteInnerCode string + RemoteItemsJSON string Status InnerCodeStatus ResultMessage string CreatedByUserID string diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 6371cb4..94d21a7 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -156,7 +156,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,source_duplicate_count=?, status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=NULL, syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=NULL, - remote_inner_code=NULL,result_message=NULL,planned_at=NULL, + remote_inner_code=NULL,remote_items_json=NULL,result_message=NULL,planned_at=NULL, apply_batch_id=NULL,apply_queued_at=NULL,apply_started_at=NULL, deleted_at=NULL,deleted_by_user_id=NULL,updated_at=? WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), @@ -182,6 +182,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri purchase_platform=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_platform ELSE NULL END, purchase_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN purchase_code ELSE NULL END, remote_inner_code=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN remote_inner_code ELSE NULL END, + remote_items_json=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN remote_items_json ELSE NULL END, result_message=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN result_message ELSE NULL END, planned_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN planned_at ELSE NULL END, apply_batch_id=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_batch_id ELSE NULL END, @@ -303,7 +304,7 @@ func SaveInnerCodePlans(db *sql.DB, plans []model.InnerCodeRecord, plannedAt str UPDATE syb_inner_code_records SET stock_id=?,detail_id=?,syb_spec=?,syb_sku=?,syb_variation_sku=?, purchase_platform=?,purchase_code=?,remote_inner_code=?,status=?, - result_message=?,planned_at=?,apply_batch_id=NULL,apply_queued_at=NULL, + remote_items_json=NULL,result_message=?,planned_at=?,apply_batch_id=NULL,apply_queued_at=NULL, apply_started_at=NULL,updated_at=? WHERE id=? AND deleted_at IS NULL AND status IN ('pending','ready','skipped','failed')`, nullablePositiveInt64(plan.StockID), nullablePositiveInt64(plan.DetailID), @@ -405,7 +406,7 @@ const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequenc COALESCE(apply_batch_id,''),COALESCE(apply_queued_at,''), COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''), COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''), - COALESCE(remote_inner_code,''),status,COALESCE(result_message,''),created_by_user_id, + COALESCE(remote_inner_code,''),COALESCE(remote_items_json,''),status,COALESCE(result_message,''),created_by_user_id, COALESCE(applied_by_user_id,''),COALESCE(planned_at,''),COALESCE(apply_started_at,''), COALESCE(applied_at,''),COALESCE(deleted_at,''),COALESCE(deleted_by_user_id,''),created_at,updated_at` @@ -485,7 +486,7 @@ func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, er &row.InnerCode, &row.SourceDuplicateCount, &row.ApplyBatchID, &row.ApplyQueuedAt, &row.StockID, &row.DetailID, &row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform, - &row.PurchaseCode, &row.RemoteInnerCode, &row.Status, &row.ResultMessage, + &row.PurchaseCode, &row.RemoteInnerCode, &row.RemoteItemsJSON, &row.Status, &row.ResultMessage, &row.CreatedByUserID, &row.AppliedByUserID, &row.PlannedAt, &row.ApplyStartedAt, &row.AppliedAt, &row.DeletedAt, &row.DeletedByUserID, &row.CreatedAt, &row.UpdatedAt) if err != nil { @@ -654,6 +655,21 @@ func FinishInnerCodeApply(q Execer, id int64, status model.InnerCodeStatus, mess return nil } +// SaveInnerCodeRemoteItems 在每个远端动作前后保存逐件检查点。 +// 只有 applying 记录可更新,避免后台执行器覆盖已经被人工处理的结果。 +func SaveInnerCodeRemoteItems(q Execer, id int64, remoteItemsJSON, message, updatedAt string) error { + result, err := q.Exec(`UPDATE syb_inner_code_records + SET remote_items_json=?,result_message=?,updated_at=? + WHERE id=? AND status='applying'`, nullableString(remoteItemsJSON), nullableString(message), updatedAt, id) + if err != nil { + return fmt.Errorf("保存档口入库码逐件检查点失败: %w", err) + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + return fmt.Errorf("档口入库码记录 %d 已不在回写中,拒绝保存逐件检查点", id) + } + return nil +} + // GetInnerCodeForRecheck 读取一条需核对记录。 func GetInnerCodeForRecheck(q Execer, id int64) (*model.InnerCodeRecord, error) { record, err := scanInnerCodeRecord(q.QueryRow(`SELECT `+innerCodeListColumns+ @@ -705,11 +721,11 @@ func SoftDeleteInnerCodeRecords(db *sql.DB, ids []int64, actorUserID, deletedAt } // SaveInnerCodeRecheck 保存只读重新核对的远端结果,不执行状态领取或写入。 -func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, checkedAt string) error { +func SaveInnerCodeRecheck(q Execer, id int64, status model.InnerCodeStatus, message, remoteCode, remoteItemsJSON, checkedAt string) error { result, err := q.Exec(`UPDATE syb_inner_code_records - SET status=?,result_message=?,remote_inner_code=?, + SET status=?,result_message=?,remote_inner_code=?,remote_items_json=?, applied_at=CASE WHEN ?='updated' THEN ? ELSE applied_at END,updated_at=? - WHERE id=? AND status='needs_check'`, status, message, nullableString(remoteCode), status, checkedAt, checkedAt, id) + WHERE id=? AND status='needs_check'`, status, message, nullableString(remoteCode), nullableString(remoteItemsJSON), status, checkedAt, checkedAt, id) if err != nil { return fmt.Errorf("保存档口入库码核对结果失败: %w", err) } diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 3f3f6ae..c13b3fe 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -20,7 +20,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 26 +const mysqlSchemaVersion = 27 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { @@ -737,9 +737,34 @@ func MigrateMySQL(db *sql.DB) error { return fmt.Errorf("记录 MySQL schema v26 失败: %w", err) } } + if current < 27 { + if err := migrateMySQLV27(db); err != nil { + return fmt.Errorf("执行 MySQL schema v27 失败: %w", err) + } + if err := checkMySQLV27Shape(db); err != nil { + return fmt.Errorf("MySQL schema v27 自检失败,未记录版本: %w", err) + } + if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 27, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("记录 MySQL schema v27 失败: %w", err) + } + } return CheckMySQLSchema(db) } +// migrateMySQLV27 保存多件档口入库码逐件远端检查点,不拆分新的业务表。 +func migrateMySQLV27(db *sql.DB) error { + exists, err := mysqlColumnExists(db, "syb_inner_code_records", "remote_items_json") + if err != nil { + return err + } + if exists { + return nil + } + _, err = db.Exec(`ALTER TABLE syb_inner_code_records + ADD COLUMN remote_items_json JSON NULL AFTER remote_inner_code`) + return err +} + // migrateMySQLV26 建立采购运行时规格解析审计。一个记录同时保存候选观察和最终决策, // 不覆盖 PDD 商品主数据,也不通过外键阻止任务的既有硬删除流程。 func migrateMySQLV26(db *sql.DB) error { @@ -2320,7 +2345,23 @@ func CheckMySQLSchema(db *sql.DB) error { if err := checkMySQLV25Shape(db); err != nil { return err } - return checkMySQLV26Shape(db) + if err := checkMySQLV26Shape(db); err != nil { + return err + } + return checkMySQLV27Shape(db) +} + +func checkMySQLV27Shape(db *sql.DB) error { + var dataType, nullable string + if err := db.QueryRow(`SELECT data_type,is_nullable FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='syb_inner_code_records' AND column_name='remote_items_json'`). + Scan(&dataType, &nullable); err != nil { + return fmt.Errorf("检查档口入库码逐件检查点字段失败: %w", err) + } + if dataType != "json" || nullable != "YES" { + return fmt.Errorf("档口入库码逐件检查点字段 remote_items_json 结构不正确") + } + return nil } func checkMySQLV26Shape(db *sql.DB) error { diff --git a/admin/service/inner_code_apply.go b/admin/service/inner_code_apply.go index 861f265..429b6fc 100644 --- a/admin/service/inner_code_apply.go +++ b/admin/service/inner_code_apply.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "strings" @@ -19,6 +20,7 @@ import ( type InnerCodeWriter interface { InnerCodeDetailReader DeleteInnerCode(context.Context, int64) error + CreateInnerCodeDetail(context.Context, int64, string) (int64, error) UpdateDetailCode(context.Context, int64, int64, string) error } @@ -46,6 +48,16 @@ type innerCodeApplyOutcome struct { RemoteCode string } +type innerCodeRemoteItem struct { + Code string `json:"code"` + DetailID int64 `json:"detail_id,omitempty"` + Source string `json:"source,omitempty"` + Title string `json:"title,omitempty"` + Status string `json:"status"` +} + +type innerCodeCheckpoint func([]innerCodeRemoteItem, string) error + // QueueInnerCodeApplyBatch 把任意数量的已选记录原子排队,远端请求由后台执行器处理。 func QueueInnerCodeApplyBatch(db *sql.DB, ids []int64, actorUserID string) (*InnerCodeApplyBatch, error) { ids = uniquePositiveInnerCodeIDs(ids) @@ -95,7 +107,14 @@ func RunInnerCodeApplyBatch(ctx context.Context, db *sql.DB, writer InnerCodeWri result.Skipped++ continue } - outcome := applyClaimedInnerCode(ctx, writer, *record) + checkpoint := func(items []innerCodeRemoteItem, message string) error { + raw, err := json.Marshal(items) + if err != nil { + return fmt.Errorf("序列化档口入库码逐件检查点失败: %w", err) + } + return repository.SaveInnerCodeRemoteItems(db, id, string(raw), compactInnerCodeMessage(message), model.NowISO()) + } + outcome := applyClaimedInnerCode(ctx, writer, *record, checkpoint) finishedAt := model.NowISO() if err := repository.FinishInnerCodeApply(db, id, outcome.Status, compactInnerCodeMessage(outcome.Message), outcome.RemoteCode, finishedAt); err != nil { return result, err @@ -138,80 +157,121 @@ func newInnerCodeApplyBatchID() (string, error) { return "ICB-" + time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil } -func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord) innerCodeApplyOutcome { - item, err := readCurrentInnerCodeDetail(ctx, writer, record) +func applyClaimedInnerCode(ctx context.Context, writer InnerCodeWriter, record model.InnerCodeRecord, checkpoint innerCodeCheckpoint) innerCodeApplyOutcome { + codes, err := splitInnerCodes(record.InnerCode) + if err != nil || len(codes) != record.SourceDuplicateCount { + return innerCodeApplyOutcome{Status: model.InnerCodeFailed, + Message: fmt.Sprintf("导入单件码数量与源行数不一致(入库码 %d 个,源行 %d 行),没有发送远端写请求", len(codes), record.SourceDuplicateCount), RemoteCode: record.RemoteInnerCode} + } + stock, original, err := readCurrentInnerCodeStock(ctx, writer, record) if err != nil { return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, - Message: "写入前重新读取失败,没有发送删除或写入请求:" + err.Error(), RemoteCode: record.RemoteInnerCode} + Message: "写入前重新读取失败,没有发送远端写请求:" + err.Error(), RemoteCode: record.RemoteInnerCode} } - currentCode := innerCodeRawText(item.Raw["innerExpCode"]) - if !innerCodeDetailIdentityMatches(record, *item) { + if original.ProductQty != len(codes) { + return innerCodeApplyOutcome{Status: model.InnerCodeFailed, + Message: fmt.Sprintf("顺运宝商品数量为 %d,单件入库码为 %d 个,数量不一致;没有发送远端写请求", original.ProductQty, len(codes)), RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])} + } + if !innerCodeDetailIdentityMatches(record, *original) { return innerCodeApplyOutcome{Status: model.InnerCodeSkipped, - Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: currentCode} + Message: "顺运宝商品规格或档口身份已变化,停止回写,请重新匹配", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])} } - platform := innerCodeRawText(item.Raw["purchasePlatform"]) - purchaseCode := innerCodeRawText(item.Raw["purchaseCode"]) - if platform != "" || purchaseCode != "" { + if innerCodeRawText(original.Raw["purchasePlatform"]) != "" || innerCodeRawText(original.Raw["purchaseCode"]) != "" { return innerCodeApplyOutcome{Status: model.InnerCodeSkipped, - Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: currentCode} + Message: "顺运宝商品已有采购平台或采购单号,停止回写", RemoteCode: innerCodeRawText(original.Raw["innerExpCode"])} } - if currentCode == record.InnerCode { + items, missing, prepareErr := planInnerCodeRemoteItems(record, stock, codes) + if prepareErr != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: prepareErr.Error(), RemoteCode: record.RemoteInnerCode} + } + if checkpoint == nil { + checkpoint = func([]innerCodeRemoteItem, string) error { return nil } + } + if len(missing) == 0 { + if err := checkpoint(items, fmt.Sprintf("远端已存在全部 %d 个单件入库码", len(codes))); err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: strings.Join(codes, ",")} + } return innerCodeApplyOutcome{Status: model.InnerCodeAlreadyFilled, - Message: "写入前核验发现远端已是目标入库码,无需重复写入", RemoteCode: currentCode} + Message: fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes)), RemoteCode: strings.Join(codes, ",")} } - if currentCode != record.RemoteInnerCode { - return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, - Message: "远端快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode} - } - if currentCode != "" { + + currentCode := innerCodeRawText(original.Raw["innerExpCode"]) + if currentCode != "" && !containsInnerCode(codes, currentCode) { + if currentCode != record.RemoteInnerCode { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, + Message: "原商品快递单号在规划后发生变化,已停止回写,请人工核对", RemoteCode: currentCode} + } + if err := checkpoint(items, "准备清除原商品上的旧快递单号"); err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: currentCode} + } if err := writer.DeleteInnerCode(ctx, record.DetailID); err != nil { - status := model.InnerCodeFailed - message := "删除旧快递单号失败,未发送新值写入请求:" + err.Error() - if errors.Is(err, syb.ErrWriteResultUnknown) { - status = model.InnerCodeNeedsCheck - message = "删除旧快递单号的结果未知,禁止自动继续写入,请重新核对" - } - return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode} + return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, "删除旧快递单号", currentCode), items) } currentCode = "" } - if err := writer.UpdateDetailCode(ctx, record.StockID, record.DetailID, record.InnerCode); err != nil { - status := model.InnerCodeFailed - message := "写入档口入库码失败,系统不会自动重试:" + err.Error() - if errors.Is(err, syb.ErrWriteResultUnknown) { - status = model.InnerCodeNeedsCheck - message = "写入结果未知,禁止自动重试,请重新核对" + + for _, codeIndex := range missing { + item := &items[codeIndex] + if item.DetailID == 0 && currentCode == "" { + item.DetailID = record.DetailID + item.Source = "original" + currentCode = "reserved" + } + if item.DetailID == 0 { + item.Title = innerCodePlaceholderTitle(record.ID, codeIndex+1) + item.Status = "creating" + if err := checkpoint(items, fmt.Sprintf("准备为第 %d 件创建零价明细", codeIndex+1)); err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: record.RemoteInnerCode} + } + detailID, err := writer.CreateInnerCodeDetail(ctx, record.StockID, item.Title) + if err != nil { + return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, fmt.Sprintf("创建第 %d 件明细", codeIndex+1), record.RemoteInnerCode), items) + } + item.DetailID = detailID + item.Source = "created" + item.Status = "created" + if err := checkpoint(items, fmt.Sprintf("第 %d 件明细已创建,准备写入入库码", codeIndex+1)); err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "新增明细后保存检查点失败,禁止继续写入:" + err.Error(), RemoteCode: record.RemoteInnerCode} + } + } + item.Status = "writing" + if err := checkpoint(items, fmt.Sprintf("准备写入第 %d 个单件入库码", codeIndex+1)); err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: err.Error(), RemoteCode: record.RemoteInnerCode} + } + if err := writer.UpdateDetailCode(ctx, record.StockID, item.DetailID, item.Code); err != nil { + return withInnerCodeProgress(unknownOrFailedInnerCodeOutcome(err, fmt.Sprintf("写入第 %d 个单件入库码", codeIndex+1), record.RemoteInnerCode), items) + } + verifiedStock, _, err := readCurrentInnerCodeStock(ctx, writer, record) + if err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入成功响应后重新读取失败,禁止自动重试", RemoteCode: record.RemoteInnerCode} + } + if countInnerCodeInStock(verifiedStock, item.Code) != 1 || innerCodeDetailCode(verifiedStock, item.DetailID) != item.Code { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入后单件入库码未唯一出现在预期明细,禁止自动重试", RemoteCode: record.RemoteInnerCode} + } + item.Status = "confirmed" + if err := checkpoint(items, fmt.Sprintf("第 %d 个单件入库码已回读确认", codeIndex+1)); err != nil { + return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, Message: "写入确认后保存检查点失败,请人工核对:" + err.Error(), RemoteCode: record.RemoteInnerCode} } - return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: currentCode} - } - verified, err := readCurrentInnerCodeDetail(ctx, writer, record) - if err != nil { - return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, - Message: "写入请求已成功响应,但重新读取失败,请核对远端结果", RemoteCode: currentCode} - } - verifiedCode := innerCodeRawText(verified.Raw["innerExpCode"]) - if !innerCodeDetailIdentityMatches(record, *verified) { - return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, - Message: "写入后商品规格或档口身份发生变化,请人工核对", RemoteCode: verifiedCode} - } - if verifiedCode != record.InnerCode { - return innerCodeApplyOutcome{Status: model.InnerCodeNeedsCheck, - Message: "写入后远端值与目标入库码不一致,禁止自动重试,请人工核对", RemoteCode: verifiedCode} } return innerCodeApplyOutcome{Status: model.InnerCodeUpdated, - Message: "回写完成,远端再次读取结果一致", RemoteCode: verifiedCode} + Message: fmt.Sprintf("回写完成:%d 个单件入库码均已逐件回读确认", len(codes)), RemoteCode: strings.Join(codes, ",")} } func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (*syb.DetailItem, error) { + _, found, err := readCurrentInnerCodeStock(ctx, reader, record) + return found, err +} + +func readCurrentInnerCodeStock(ctx context.Context, reader InnerCodeDetailReader, record model.InnerCodeRecord) (syb.StockDetail, *syb.DetailItem, error) { if record.StockID <= 0 || record.DetailID <= 0 { - return nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID") + return syb.StockDetail{}, nil, fmt.Errorf("记录缺少有效的货运单或商品明细 ID") } stocks, err := reader.DetailListByStock(ctx, []int64{record.StockID}) if err != nil { - return nil, err + return syb.StockDetail{}, nil, err } if len(stocks) != 1 || stocks[0].ID != record.StockID { - return nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID) + return syb.StockDetail{}, nil, fmt.Errorf("顺运宝没有唯一返回货运单 id=%d", record.StockID) } var found *syb.DetailItem for index := range stocks[0].Details { @@ -219,15 +279,134 @@ func readCurrentInnerCodeDetail(ctx context.Context, reader InnerCodeDetailReade continue } if found != nil { - return nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID) + return syb.StockDetail{}, nil, fmt.Errorf("顺运宝重复返回商品明细 id=%d", record.DetailID) } item := stocks[0].Details[index] found = &item } if found == nil { - return nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID) + return syb.StockDetail{}, nil, fmt.Errorf("顺运宝未返回商品明细 id=%d", record.DetailID) } - return found, nil + return stocks[0], found, nil +} + +func splitInnerCodes(raw string) ([]string, error) { + parts := strings.Split(raw, ",") + codes := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, part := range parts { + code := strings.TrimSpace(part) + if code == "" { + return nil, fmt.Errorf("单件入库码不能为空") + } + if seen[code] { + return nil, fmt.Errorf("单件入库码 %q 重复", code) + } + seen[code] = true + codes = append(codes, code) + } + return codes, nil +} + +func planInnerCodeRemoteItems(record model.InnerCodeRecord, stock syb.StockDetail, codes []string) ([]innerCodeRemoteItem, []int, error) { + items := make([]innerCodeRemoteItem, len(codes)) + indexByCode := make(map[string]int, len(codes)) + for index, code := range codes { + items[index] = innerCodeRemoteItem{Code: code, Status: "planned"} + indexByCode[code] = index + } + for _, detail := range stock.Details { + code := innerCodeRawText(detail.Raw["innerExpCode"]) + if index, ok := indexByCode[code]; ok { + if items[index].DetailID != 0 { + return nil, nil, fmt.Errorf("单件入库码 %s 在货运单中出现多次,停止自动回写", code) + } + source := "existing" + if detail.ID == record.DetailID { + source = "original" + } + items[index].DetailID = detail.ID + items[index].Source = source + items[index].Status = "confirmed" + continue + } + for index := range items { + title := innerCodePlaceholderTitle(record.ID, index+1) + if detail.ProductTitle != title { + continue + } + if items[index].DetailID != 0 { + return nil, nil, fmt.Errorf("第 %d 件占位明细在货运单中出现多次,停止自动回写", index+1) + } + if code != "" { + return nil, nil, fmt.Errorf("第 %d 件占位明细已有非目标快递单号,停止自动回写", index+1) + } + items[index].DetailID = detail.ID + items[index].Source = "existing_placeholder" + items[index].Title = title + items[index].Status = "created" + } + } + missing := make([]int, 0, len(items)) + for index := range items { + if items[index].Status != "confirmed" { + missing = append(missing, index) + } + } + return items, missing, nil +} + +func innerCodePlaceholderTitle(recordID int64, ordinal int) string { + return fmt.Sprintf("档口第%d件-IC%d", ordinal, recordID) +} + +func containsInnerCode(codes []string, value string) bool { + for _, code := range codes { + if code == value { + return true + } + } + return false +} + +func countInnerCodeInStock(stock syb.StockDetail, code string) int { + count := 0 + for _, detail := range stock.Details { + if innerCodeRawText(detail.Raw["innerExpCode"]) == code { + count++ + } + } + return count +} + +func innerCodeDetailCode(stock syb.StockDetail, detailID int64) string { + for _, detail := range stock.Details { + if detail.ID == detailID { + return innerCodeRawText(detail.Raw["innerExpCode"]) + } + } + return "" +} + +func unknownOrFailedInnerCodeOutcome(err error, action, remoteCode string) innerCodeApplyOutcome { + status := model.InnerCodeFailed + message := action + "失败,系统不会自动重试:" + err.Error() + if errors.Is(err, syb.ErrWriteResultUnknown) { + status = model.InnerCodeNeedsCheck + message = action + "结果未知,禁止自动重试,请重新核对" + } + return innerCodeApplyOutcome{Status: status, Message: message, RemoteCode: remoteCode} +} + +func withInnerCodeProgress(outcome innerCodeApplyOutcome, items []innerCodeRemoteItem) innerCodeApplyOutcome { + confirmed := 0 + for _, item := range items { + if item.Status == "confirmed" { + confirmed++ + } + } + outcome.Message = fmt.Sprintf("已确认 %d/%d 件;%s", confirmed, len(items), outcome.Message) + return outcome } // RecheckInnerCode 只重新读取一条 needs_check 记录,不发送任何写请求。 @@ -242,24 +421,35 @@ func RecheckInnerCode(ctx context.Context, db *sql.DB, reader InnerCodeDetailRea if record.Status != model.InnerCodeNeedsCheck { return record.Status, "当前记录不需要核对", nil } - item, readErr := readCurrentInnerCodeDetail(ctx, reader, *record) + stock, item, readErr := readCurrentInnerCodeStock(ctx, reader, *record) status := model.InnerCodeNeedsCheck remoteCode := record.RemoteInnerCode + remoteItemsJSON := record.RemoteItemsJSON message := "重新读取失败,仍需人工核对:" + errorText(readErr) if readErr == nil { + codes, splitErr := splitInnerCodes(record.InnerCode) + items, missing, planErr := planInnerCodeRemoteItems(*record, stock, codes) + if splitErr == nil && planErr == nil { + if raw, err := json.Marshal(items); err == nil { + remoteItemsJSON = string(raw) + } + } remoteCode = innerCodeRawText(item.Raw["innerExpCode"]) if !innerCodeDetailIdentityMatches(*record, *item) { message = "重新读取到的商品身份与规划不一致;保持需核对,系统没有写入" - } else if remoteCode == record.InnerCode { + } else if splitErr != nil || planErr != nil { + message = "重新读取到的逐件入库码存在冲突;保持需核对,系统没有写入" + } else if len(missing) == 0 { status = model.InnerCodeUpdated - message = "重新读取确认远端已是目标入库码;没有重复写入" + remoteCode = strings.Join(codes, ",") + message = fmt.Sprintf("重新读取确认远端已存在全部 %d 个单件入库码;没有重复写入", len(codes)) } else { - message = "重新读取后远端仍不是目标入库码;保持需核对,系统没有写入" + message = fmt.Sprintf("重新读取后仍缺少 %d 个单件入库码;保持需核对,系统没有写入", len(missing)) } } checkedAt := model.NowISO() message = compactInnerCodeMessage(message) - if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, checkedAt); err != nil { + if err := repository.SaveInnerCodeRecheck(db, id, status, message, remoteCode, remoteItemsJSON, checkedAt); err != nil { return "", "", err } return status, message, nil diff --git a/admin/service/inner_code_apply_test.go b/admin/service/inner_code_apply_test.go index 5e0a19e..e76b8fa 100644 --- a/admin/service/inner_code_apply_test.go +++ b/admin/service/inner_code_apply_test.go @@ -13,14 +13,17 @@ import ( ) type fakeInnerCodeWriter struct { - stock syb.StockDetail - readErr error - deleteErr error - updateErr error - readCount int - deleteCount int - updateCount int - updatedCode string + stock syb.StockDetail + readErr error + deleteErr error + createErr error + updateErr error + readCount int + deleteCount int + createCount int + updateCount int + updatedCode string + nextDetailID int64 } func TestQueueInnerCodeApplyBatch_超过20条可一次排队(t *testing.T) { @@ -81,6 +84,20 @@ func (f *fakeInnerCodeWriter) DeleteInnerCode(_ context.Context, detailID int64) return nil } +func (f *fakeInnerCodeWriter) CreateInnerCodeDetail(_ context.Context, stockID int64, title string) (int64, error) { + f.createCount++ + if f.createErr != nil { + return 0, f.createErr + } + if f.nextDetailID == 0 { + f.nextDetailID = 100 + } + f.nextDetailID++ + f.stock.Details = append(f.stock.Details, syb.DetailItem{ID: f.nextDetailID, ProductTitle: title, + ProductQty: 1, ProductPrice: 0, Raw: map[string]any{"innerExpCode": ""}}) + return f.nextDetailID, nil +} + func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID int64, code string) error { f.updateCount++ f.updatedCode = code @@ -95,21 +112,93 @@ func (f *fakeInnerCodeWriter) UpdateDetailCode(_ context.Context, _, detailID in return nil } -func TestApplyClaimedInnerCode_聚合码只写一次并按完整值复读(t *testing.T) { +func TestApplyClaimedInnerCode_两个单件码写入两个明细(t *testing.T) { record := innerCodeApplyTestRecord("") record.InnerCode = "DK260815A160101,DK260815A160102" + record.SourceDuplicateCount = 2 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) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) + if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 2 || writer.createCount != 1 || + outcome.RemoteCode != record.InnerCode || writer.readCount != 3 { + t.Fatalf("outcome=%+v update=%d create=%d read=%d", outcome, writer.updateCount, writer.createCount, writer.readCount) + } +} + +func TestApplyClaimedInnerCode_三个单件码创建两条零价明细(t *testing.T) { + record := innerCodeApplyTestRecord("") + record.InnerCode = "DK-001,DK-002,DK-003" + record.SourceDuplicateCount = 3 + writer := innerCodeApplyTestWriter(record, "") + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) + if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 3 || writer.createCount != 2 { + t.Fatalf("outcome=%+v update=%d create=%d", outcome, writer.updateCount, writer.createCount) + } + for _, detail := range writer.stock.Details[1:] { + if detail.ProductQty != 1 || detail.ProductPrice != 0 { + t.Fatalf("占位明细必须数量 1、价格 0: %+v", detail) + } + } +} + +func TestApplyClaimedInnerCode_复用人工明细已有目标码(t *testing.T) { + record := innerCodeApplyTestRecord("") + record.InnerCode = "DK-001,DK-002" + record.SourceDuplicateCount = 2 + writer := innerCodeApplyTestWriter(record, "") + writer.stock.Details = append(writer.stock.Details, syb.DetailItem{ID: 21, ProductTitle: "人工明细", ProductQty: 1, + Raw: map[string]any{"innerExpCode": "DK-002"}}) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) + if outcome.Status != model.InnerCodeUpdated || writer.updateCount != 1 || writer.createCount != 0 || writer.deleteCount != 0 { + t.Fatalf("outcome=%+v update=%d create=%d delete=%d", outcome, writer.updateCount, writer.createCount, writer.deleteCount) + } +} + +func TestApplyClaimedInnerCode_数量不一致零写入(t *testing.T) { + record := innerCodeApplyTestRecord("") + record.InnerCode = "DK-001,DK-002" + record.SourceDuplicateCount = 2 + writer := innerCodeApplyTestWriter(record, "") + writer.stock.Details[0].ProductQty = 1 + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) + if outcome.Status != model.InnerCodeFailed || writer.updateCount != 0 || writer.createCount != 0 || writer.deleteCount != 0 { + t.Fatalf("outcome=%+v update=%d create=%d delete=%d", outcome, writer.updateCount, writer.createCount, writer.deleteCount) + } +} + +func TestApplyClaimedInnerCode_创建结果未知立即停止(t *testing.T) { + record := innerCodeApplyTestRecord("") + record.InnerCode = "DK-001,DK-002" + record.SourceDuplicateCount = 2 + writer := innerCodeApplyTestWriter(record, "") + writer.createErr = fmtUnknownInnerCodeWriteError() + checkpoints := 0 + outcome := applyClaimedInnerCode(context.Background(), writer, record, func([]innerCodeRemoteItem, string) error { + checkpoints++ + return nil + }) + if outcome.Status != model.InnerCodeNeedsCheck || writer.createCount != 1 || writer.updateCount != 1 || checkpoints < 3 { + t.Fatalf("outcome=%+v create=%d update=%d checkpoints=%d", outcome, writer.createCount, writer.updateCount, checkpoints) + } +} + +func TestApplyClaimedInnerCode_崩溃遗留占位明细会复用而不重复创建(t *testing.T) { + record := innerCodeApplyTestRecord("") + record.InnerCode = "DK-001,DK-002" + record.SourceDuplicateCount = 2 + writer := innerCodeApplyTestWriter(record, "") + writer.stock.Details = append(writer.stock.Details, syb.DetailItem{ID: 99, + ProductTitle: innerCodePlaceholderTitle(record.ID, 2), ProductQty: 1, ProductPrice: 0, + Raw: map[string]any{"innerExpCode": ""}}) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) + if outcome.Status != model.InnerCodeUpdated || writer.createCount != 0 || writer.updateCount != 2 { + t.Fatalf("outcome=%+v create=%d update=%d", outcome, writer.createCount, writer.updateCount) } } func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) { record := innerCodeApplyTestRecord("") writer := innerCodeApplyTestWriter(record, "") - outcome := applyClaimedInnerCode(context.Background(), writer, record) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 0 || writer.updateCount != 1 || writer.readCount != 2 { t.Fatalf("outcome=%+v counts read=%d delete=%d update=%d", outcome, writer.readCount, writer.deleteCount, writer.updateCount) } @@ -118,7 +207,7 @@ func TestApplyClaimedInnerCode_空旧值直接写并复读确认(t *testing.T) { func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) { record := innerCodeApplyTestRecord("OLD") writer := innerCodeApplyTestWriter(record, "OLD") - outcome := applyClaimedInnerCode(context.Background(), writer, record) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) if outcome.Status != model.InnerCodeUpdated || writer.deleteCount != 1 || writer.updateCount != 1 { t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) } @@ -127,7 +216,7 @@ func TestApplyClaimedInnerCode_有旧值先删后写(t *testing.T) { func TestApplyClaimedInnerCode_规划后远端值变化不写入(t *testing.T) { record := innerCodeApplyTestRecord("OLD") writer := innerCodeApplyTestWriter(record, "OTHER") - outcome := applyClaimedInnerCode(context.Background(), writer, record) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) if outcome.Status != model.InnerCodeNeedsCheck || writer.deleteCount != 0 || writer.updateCount != 0 { t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) } @@ -137,7 +226,7 @@ func TestApplyClaimedInnerCode_删除明确失败不继续写(t *testing.T) { record := innerCodeApplyTestRecord("OLD") writer := innerCodeApplyTestWriter(record, "OLD") writer.deleteErr = errors.New("已打单数据不能清除") - outcome := applyClaimedInnerCode(context.Background(), writer, record) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) if outcome.Status != model.InnerCodeFailed || writer.deleteCount != 1 || writer.updateCount != 0 { t.Fatalf("outcome=%+v delete=%d update=%d", outcome, writer.deleteCount, writer.updateCount) } @@ -147,7 +236,7 @@ func TestApplyClaimedInnerCode_写入未知结果不复读也不重试(t *testin record := innerCodeApplyTestRecord("") writer := innerCodeApplyTestWriter(record, "") writer.updateErr = fmtUnknownInnerCodeWriteError() - outcome := applyClaimedInnerCode(context.Background(), writer, record) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) if outcome.Status != model.InnerCodeNeedsCheck || writer.updateCount != 1 || writer.readCount != 1 { t.Fatalf("outcome=%+v update=%d read=%d", outcome, writer.updateCount, writer.readCount) } @@ -157,7 +246,7 @@ func TestApplyClaimedInnerCode_采购字段出现后停止(t *testing.T) { record := innerCodeApplyTestRecord("") writer := innerCodeApplyTestWriter(record, "") writer.stock.Details[0].Raw["purchaseCode"] = "CG-1" - outcome := applyClaimedInnerCode(context.Background(), writer, record) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 { t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount) } @@ -168,7 +257,7 @@ func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) { record.SybSKU = "A#1" writer := innerCodeApplyTestWriter(record, "") writer.stock.Details[0].Raw["sku"] = "B#2" - outcome := applyClaimedInnerCode(context.Background(), writer, record) + outcome := applyClaimedInnerCode(context.Background(), writer, record, nil) if outcome.Status != model.InnerCodeSkipped || writer.updateCount != 0 { t.Fatalf("outcome=%+v update=%d", outcome, writer.updateCount) } @@ -176,11 +265,11 @@ func TestApplyClaimedInnerCode_档口身份变化后停止(t *testing.T) { func innerCodeApplyTestRecord(oldCode string) model.InnerCodeRecord { return model.InnerCodeRecord{ID: 1, StockID: 10, DetailID: 20, SybSpec: "黑色,M", - InnerCode: "DK-001", RemoteInnerCode: oldCode, Status: model.InnerCodeApplying} + InnerCode: "DK-001", SourceDuplicateCount: 1, RemoteInnerCode: oldCode, Status: model.InnerCodeApplying} } func innerCodeApplyTestWriter(record model.InnerCodeRecord, currentCode string) *fakeInnerCodeWriter { - item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, Raw: map[string]any{ + item := syb.DetailItem{ID: record.DetailID, ProductSpec: record.SybSpec, ProductQty: record.SourceDuplicateCount, Raw: map[string]any{ "id": record.DetailID, "productSpec": record.SybSpec, "innerExpCode": currentCode, "purchasePlatform": "", "purchaseCode": "", }} diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go index 81200da..7062bc2 100644 --- a/admin/service/inner_code_match.go +++ b/admin/service/inner_code_match.go @@ -277,6 +277,17 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr continue } matched := matches[0] + codes, codeErr := splitInnerCodes(record.InnerCode) + if codeErr != nil || len(codes) != record.SourceDuplicateCount { + plans = append(plans, innerCodeSkippedPlan(plan, + fmt.Sprintf("单件入库码数量与 Excel 源行数不一致(入库码 %d 个,源行 %d 行)", len(codes), record.SourceDuplicateCount))) + continue + } + if matched.ProductQty != len(codes) { + plans = append(plans, innerCodeSkippedPlan(plan, + fmt.Sprintf("顺运宝商品数量为 %d,单件入库码为 %d 个,数量不一致", matched.ProductQty, len(codes)))) + continue + } used[record.OrderNumber][matched.ID] = true plan.StockID = stockID plan.DetailID = matched.ID @@ -286,9 +297,13 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr plan.PurchasePlatform = innerCodeRawText(matched.Raw["purchasePlatform"]) plan.PurchaseCode = innerCodeRawText(matched.Raw["purchaseCode"]) plan.RemoteInnerCode = innerCodeRawText(matched.Raw["innerExpCode"]) - if plan.RemoteInnerCode == record.InnerCode { + _, missing, remoteErr := planInnerCodeRemoteItems(plan, stock, codes) + if remoteErr != nil { + plan.Status = model.InnerCodeSkipped + plan.ResultMessage = remoteErr.Error() + } else if len(missing) == 0 { plan.Status = model.InnerCodeAlreadyFilled - plan.ResultMessage = "远端已是相同入库码,无需重复写入" + plan.ResultMessage = fmt.Sprintf("远端已存在全部 %d 个单件入库码,无需重复写入", len(codes)) } else { plan.Status = model.InnerCodeReady plan.ResultMessage = "唯一匹配,等待操作员确认回写" diff --git a/admin/service/inner_code_match_test.go b/admin/service/inner_code_match_test.go index 49203d9..08cdeea 100644 --- a/admin/service/inner_code_match_test.go +++ b/admin/service/inner_code_match_test.go @@ -179,9 +179,9 @@ func TestPlanInnerCodeRows_精确与标准化规格均受档口约束(t *testing } } -func TestPlanInnerCodeRows_聚合码放行且其他异常继续阻断(t *testing.T) { +func TestPlanInnerCodeRows_多件数量一致放行且其他异常继续阻断(t *testing.T) { records := []model.InnerCodeRecord{ - {ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1", SourceDuplicateCount: 2}, + {ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1,DK2", SourceDuplicateCount: 2}, {ID: 2, OrderNumber: "EMPTY", SpecRaw: "", InnerCode: "DK2", SourceDuplicateCount: 1}, {ID: 3, OrderNumber: "PURCHASED", SpecRaw: "黑色,M", InnerCode: "DK3", SourceDuplicateCount: 1}, {ID: 4, OrderNumber: "AMBIGUOUS", SpecRaw: "黑色,M", InnerCode: "DK4", SourceDuplicateCount: 1}, @@ -189,7 +189,7 @@ func TestPlanInnerCodeRows_聚合码放行且其他异常继续阻断(t *testing } stocks := map[string][]int64{"DUP": {1}, "EMPTY": {2}, "PURCHASED": {3}, "AMBIGUOUS": {4}} details := map[int64]syb.StockDetail{ - 1: {ID: 1, Details: []syb.DetailItem{innerCodeTestItem(1, "黑色,M", nil)}}, + 1: {ID: 1, Details: []syb.DetailItem{func() syb.DetailItem { item := innerCodeTestItem(1, "黑色,M", nil); item.ProductQty = 2; return item }()}}, 2: {ID: 2, Details: []syb.DetailItem{innerCodeTestItem(2, "黑色,M", nil)}}, 3: {ID: 3, Details: []syb.DetailItem{innerCodeTestItem(3, "黑色,M", map[string]any{"purchasePlatform": "PDD"})}}, 4: {ID: 4, Details: []syb.DetailItem{innerCodeTestItem(4, "黑色,M", nil), innerCodeTestItem(5, "黑色,M", nil)}}, @@ -275,5 +275,5 @@ func innerCodeTestItem(id int64, spec string, raw map[string]any) syb.DetailItem } raw["id"] = id raw["productSpec"] = spec - return syb.DetailItem{ID: id, ProductSpec: spec, Raw: raw} + return syb.DetailItem{ID: id, ProductSpec: spec, ProductQty: 1, Raw: raw} } diff --git a/admin/service/inner_code_page.go b/admin/service/inner_code_page.go index a8041db..d9db661 100644 --- a/admin/service/inner_code_page.go +++ b/admin/service/inner_code_page.go @@ -49,6 +49,8 @@ type InnerCodeRowView struct { CanMatch bool CanApply bool HasRemoteOldCode bool + CodeCount int + ExpectedAddCount int CanRecheck bool } @@ -133,6 +135,10 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView { record.Status == model.InnerCodeSkipped || record.Status == model.InnerCodeFailed canApply := record.Status == model.InnerCodeReady + codeCount := record.SourceDuplicateCount + if codeCount < 1 { + codeCount = 1 + } return InnerCodeRowView{ ID: record.ID, BusinessDate: record.BusinessDate, OrderNumber: record.OrderNumber, Stall: displayInnerCodeValue(record.Stall), SpecRaw: displayInnerCodeValue(record.SpecRaw), @@ -143,6 +149,8 @@ func innerCodeRowView(record model.InnerCodeRecord) InnerCodeRowView { CanMatch: canMatch, CanApply: canApply, HasRemoteOldCode: canApply && record.RemoteInnerCode != "", + CodeCount: codeCount, + ExpectedAddCount: max(codeCount-1, 0), CanRecheck: record.Status == model.InnerCodeNeedsCheck, } } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index a654192..03194c8 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -435,12 +435,22 @@ var replacements = applySelected.filter(function (box) { return box.getAttribute("data-inner-code-old-value") === "1"; }).length; + var codeTotal = applySelected.reduce(function (total, box) { + return total + Number(box.getAttribute("data-inner-code-code-count") || 0); + }, 0); + var addTotal = applySelected.reduce(function (total, box) { + return total + Number(box.getAttribute("data-inner-code-add-count") || 0); + }, 0); 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 = applySelected.length; }); document.querySelectorAll("[data-inner-code-replace-count]") .forEach(function (node) { node.textContent = replacements; }); + document.querySelectorAll("[data-inner-code-code-total]") + .forEach(function (node) { node.textContent = codeTotal; }); + document.querySelectorAll("[data-inner-code-add-total]") + .forEach(function (node) { node.textContent = addTotal; }); document.querySelectorAll("[data-inner-code-delete-count]") .forEach(function (node) { node.textContent = deleteSelected.length; }); } diff --git a/admin/syb/client.go b/admin/syb/client.go index 2dd1b9c..4392551 100644 --- a/admin/syb/client.go +++ b/admin/syb/client.go @@ -753,6 +753,30 @@ func (c *Client) DeleteInnerCode(ctx context.Context, detailID int64) error { return classifyInnerCodeWriteError(err) } +// CreateInnerCodeDetail 为同一货运单额外创建一件零价占位商品,并返回新的 detailId。 +// 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得自动重试。 +func (c *Client) CreateInnerCodeDetail(ctx context.Context, stockID int64, title string) (int64, error) { + title = strings.TrimSpace(title) + if stockID <= 0 { + return 0, fmt.Errorf("货运单 id 必须是正整数") + } + if title == "" || utf8.RuneCountInString(title) > 128 { + return 0, fmt.Errorf("占位商品标题不能为空且不能超过 128 个字符") + } + data, err := c.do(ctx, http.MethodPost, "/am/stock/detail/createDetail", nil, map[string]any{ + "id": nil, "productTitle": title, "productSpec": nil, + "productQty": 1, "productPrice": 0, "stockId": stockID, + }) + if err != nil { + return 0, classifyInnerCodeWriteError(err) + } + var detailID int64 + if err := json.Unmarshal(data, &detailID); err != nil || detailID <= 0 { + return 0, fmt.Errorf("%w:新增明细响应缺少有效 detailId", ErrWriteResultUnknown) + } + return detailID, nil +} + // UpdateDetailCode 把档口入库码写入货运明细的 innerExpCode。 // 请求只发送一次;结果未知时返回 ErrWriteResultUnknown,调用方不得重试。 func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, code string) error { @@ -771,8 +795,8 @@ func (c *Client) UpdateDetailCode(ctx context.Context, stockID, detailID int64, return fmt.Errorf("code 不能包含控制字符") } } - _, err := c.do(ctx, http.MethodGet, "/am/stock/detail/updateDetailCode", url.Values{ - "t": {"0"}, "id": {strconv.FormatInt(stockID, 10)}, + _, err := c.do(ctx, http.MethodPost, "/am/stock/detail/updateDetailCode", url.Values{ + "t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}, "id": {strconv.FormatInt(stockID, 10)}, "detailId": {strconv.FormatInt(detailID, 10)}, "code": {code}, }, nil) return classifyInnerCodeWriteError(err) diff --git a/admin/syb/client_test.go b/admin/syb/client_test.go index 4bc4d8f..2d7073b 100644 --- a/admin/syb/client_test.go +++ b/admin/syb/client_test.go @@ -569,8 +569,8 @@ func TestClient_DetailListByStock_超过100个id报错(t *testing.T) { } } -func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次(t *testing.T) { - targetCode := "DK260815A160101,DK260815A160102" +func TestClient_InnerCodeWrite_单件码参数和路径正确且只发送一次(t *testing.T) { + targetCode := "DK260815A160101" var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) @@ -582,7 +582,7 @@ func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次( } case "/am/stock/detail/updateDetailCode": query := r.URL.Query() - if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != targetCode { + if r.Method != http.MethodPost || query.Get("t") == "" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != targetCode { t.Errorf("update query=%v", query) } default: @@ -606,6 +606,28 @@ func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次( } } +func TestClient_CreateInnerCodeDetail_使用零价单件请求并返回ID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/am/stock/detail/createDetail" { + t.Fatalf("request=%s %s", r.Method, r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["stockId"] != float64(11) || body["productQty"] != float64(1) || body["productPrice"] != float64(0) || body["productTitle"] != "档口第2件-IC1" { + t.Fatalf("body=%v", body) + } + io.WriteString(w, `{"status":true,"msg":"创建成功","data":147372531,"code":null}`) + })) + defer server.Close() + client, _ := New(server.URL) + detailID, err := client.CreateInnerCodeDetail(context.Background(), 11, "档口第2件-IC1") + if err != nil || detailID != 147372531 { + t.Fatalf("detailID=%d err=%v", detailID, err) + } +} + func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) { for _, tc := range []struct { name string diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index 73f08c8..007b5cf 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -129,6 +129,8 @@ {{.BusinessDate}} @@ -178,11 +180,14 @@