package web import ( "context" "errors" "fmt" "io" "log" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/gin-gonic/gin" "cmautobuy/admin/config" "cmautobuy/admin/model" "cmautobuy/admin/service" "cmautobuy/admin/syb" ) var innerCodeLocation = time.FixedZone("UTC+8", 8*60*60) const ( innerCodeFeedbackSuccess = "success" innerCodeFeedbackError = "error" ) // InnerCodeList 渲染独立的档口入库码工作台。 func (h *Handler) InnerCodeList(c *gin.Context) { businessDate := strings.TrimSpace(c.Query("date")) if _, err := time.Parse("2006-01-02", businessDate); err != nil { businessDate = time.Now().In(innerCodeLocation).Format("2006-01-02") } status := strings.TrimSpace(c.Query("status")) keyword := strings.TrimSpace(c.Query("q")) feedbackKind := strings.TrimSpace(c.Query("feedback")) feedbackMessage := strings.TrimSpace(c.Query("message")) if feedbackKind != innerCodeFeedbackSuccess && feedbackKind != innerCodeFeedbackError { feedbackKind, feedbackMessage = "", "" } pageSize := service.ParsePageSize(c.Query("page_size")) result, err := service.ListInnerCodePageWithPageSize(h.db, businessDate, status, keyword, service.ParsePage(c.Query("page")), pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取档口入库码列表失败,请稍后刷新重试。") return } query := url.Values{"date": {businessDate}} if result.Status != "" { query.Set("status", result.Status) } if keyword != "" { query.Set("q", keyword) } applyBatchID := strings.TrimSpace(c.Query("apply_batch_id")) applyProgress, err := service.GetInnerCodeApplyBatchProgress(h.db, applyBatchID) if err != nil { fail(c, http.StatusInternalServerError, "读取后台回写进度失败,请稍后刷新重试。") return } if applyProgress != nil { query.Set("apply_batch_id", applyProgress.BatchID) } progressQuery := cloneURLValues(query) progressQuery.Set("page", strconv.Itoa(result.Page)) progressQuery.Set("page_size", strconv.Itoa(pageSize)) c.HTML(http.StatusOK, "inner_code/list", page(c, "inner-codes", "档口入库码", gin.H{ "BusinessDate": businessDate, "StatusFilter": result.Status, "Keyword": keyword, "StatusOptions": service.InnerCodeStatusOptions(), "Rows": result.Rows, "HasAny": result.Counts.Total > 0, "IsFiltered": result.IsFiltered, "CurrentPage": result.Page, "CurrentPageSize": pageSize, "FeedbackMessage": feedbackMessage, "FeedbackSuccess": feedbackMessage != "" && feedbackKind == innerCodeFeedbackSuccess, "FeedbackError": feedbackMessage != "" && feedbackKind == innerCodeFeedbackError, "Status": service.InnerCodeStatusMessage(result), "ApplyProgress": applyProgress, "ApplyProgressURL": "/inner-codes?" + progressQuery.Encode(), "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, query.Encode()), })) } // InnerCodeImport 接收一次性 xlsx,导入完成后总会删除临时文件。 func (h *Handler) InnerCodeImport(c *gin.Context) { fileHeader, err := c.FormFile("file") if err != nil { fail(c, http.StatusBadRequest, "没有收到 Excel(也可能超过 10MB);没有导入任何数据。") return } src, err := fileHeader.Open() if err != nil { fail(c, http.StatusBadRequest, "无法打开上传文件;没有导入任何数据。") return } defer src.Close() head := make([]byte, 8) n, _ := io.ReadFull(src, head) if err := service.ValidateInnerCodeUpload(fileHeader.Filename, fileHeader.Size, head[:n]); err != nil { fail(c, http.StatusBadRequest, err.Error()+";没有导入任何数据。") return } if _, err := src.Seek(0, io.SeekStart); err != nil { fail(c, http.StatusInternalServerError, "读取上传文件失败;没有导入任何数据。") return } uploadDir, err := config.SubDir("uploads") if err != nil { fail(c, http.StatusInternalServerError, "无法准备临时上传目录;没有导入任何数据。") return } tmp, err := os.CreateTemp(uploadDir, "inner-code-*.xlsx") if err != nil { fail(c, http.StatusInternalServerError, "无法保存临时上传文件;没有导入任何数据。") return } tmpPath := tmp.Name() defer os.Remove(tmpPath) if _, err := io.Copy(tmp, src); err != nil { tmp.Close() fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。") return } if err := tmp.Close(); err != nil { fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。") return } result, err := service.ImportInnerCodeExcel(h.db, tmpPath, fileHeader.Filename, currentUser(c).UserID) if err != nil { if service.IsInvalidInnerCodeImport(err) { fail(c, http.StatusBadRequest, err.Error()+";请修正文件后重新导入。") return } fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。") return } message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,同业务键合并 %d 行。", result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.MergedRows) h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, innerCodeFeedbackSuccess, message) } // InnerCodeDelete 软删除当前页已选记录;不撤销任何顺运宝远端操作。 func (h *Handler) InnerCodeDelete(c *gin.Context) { businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") pageNumber := service.ParsePage(c.PostForm("page")) ids := parseInnerCodeIDs(c.PostFormArray("ids")) count, err := service.DeleteInnerCodes(h.db, ids, currentUser(c).UserID, time.Now()) if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "删除失败:"+err.Error()+";没有部分删除,请刷新后重试。") return } h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackSuccess, fmt.Sprintf("已删除 %d 条档口入库码记录;顺运宝已写入的快递单号不会撤销。重新导入相同数据可恢复。", count)) } // InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。 func (h *Handler) InnerCodeMatch(c *gin.Context) { businessDate := strings.TrimSpace(c.PostForm("date")) status, keyword := c.PostForm("status"), c.PostForm("q") pageNumber := service.ParsePage(c.PostForm("page")) if _, err := time.Parse("2006-01-02", businessDate); err != nil { h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1, innerCodeFeedbackError, "业务日期无效,没有执行匹配。") return } ids := parseInnerCodeIDs(c.PostFormArray("ids")) if len(ids) == 0 { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "请先勾选要匹配的记录。") return } cfg, err := config.Load() if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "顺运宝配置不可用,没有执行匹配。") return } client, err := syb.New(cfg.Syb.BaseURL) if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "顺运宝地址配置有误,没有执行匹配。") return } autoLoggedIn, message := ensureInnerCodeMatchSession( func() error { return service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()) }, func() (string, bool) { return h.attemptSybAutoLogin(c, client, *cfg) }, ) if message != "" { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message) return } result, err := service.PlanInnerCodeRecords(c.Request.Context(), h.db, client, businessDate, ids) if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "匹配失败:"+err.Error()+";本次没有保存不完整的匹配结果,请按提示处理后重试。") return } message = fmt.Sprintf("匹配完成:处理 %d 条,可回写 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条。", result.Total, result.Ready, result.AlreadyFilled, result.Skipped, result.Failed) if autoLoggedIn { message = "顺运宝自动登录成功;" + message } feedbackKind := innerCodeFeedbackSuccess if result.Failed > 0 || result.Skipped > 0 { feedbackKind = innerCodeFeedbackError message += " 请查看状态为“已跳过”或“失败”的记录,按逐行原因处理后重新勾选匹配。" } h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message) } // ensureInnerCodeMatchSession 只负责“检查缓存 → 必要时自动登录”的编排。 // 自动登录成功后调用方继续使用同一个 client;这里绝不启动顺运宝同步。 func ensureInnerCodeMatchSession(checkSession func() error, autoLogin func() (string, bool)) (bool, string) { err := checkSession() if err == nil { return false, "" } if !errors.Is(err, service.ErrSybLoginRequired) { return false, "校验顺运宝会话失败,没有执行匹配。" } reason, ok := autoLogin() if ok { return true, "" } reason = strings.TrimSpace(reason) if reason == "" { reason = "未配置验证码识别服务,无法自动登录" } return false, "顺运宝自动登录未完成:" + reason + ";没有执行匹配,请到“顺运宝数据”页面完成登录。" } // InnerCodeApply 在操作员确认后落库排队并立即返回,真实回写由单一后台执行器串行处理。 func (h *Handler) InnerCodeApply(c *gin.Context) { businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") pageNumber := service.ParsePage(c.PostForm("page")) ids := parseInnerCodeIDs(c.PostFormArray("ids")) _, message := h.innerCodeSybClient() if message != "" { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message) return } actorUserID := currentUser(c).UserID batch, err := service.QueueInnerCodeApplyBatch(h.db, ids, actorUserID) if err != nil { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "后台回写未提交:"+err.Error()) return } h.startInnerCodeApplyBatch(batch.ID, actorUserID) message = fmt.Sprintf("已提交 %d 条后台回写,页面可以继续使用;请刷新进度查看逐条结果。", batch.Count) h.innerCodeRedirectWithBatch(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackSuccess, message, batch.ID) } func (h *Handler) startInnerCodeApplyBatch(batchID, actorUserID string) { go func() { h.innerCodeApplyMu.Lock() defer h.innerCodeApplyMu.Unlock() var runErr error defer func() { if recovered := recover(); recovered != nil { runErr = fmt.Errorf("后台执行器异常:%v", recovered) } if runErr == nil { return } needsCheck, released, interruptErr := service.InterruptInnerCodeApplyBatch(h.db, batchID) if interruptErr != nil { log.Printf("档口入库码后台批次中断且状态收敛失败 batch_id=%s err=%v interrupt_err=%v", batchID, runErr, interruptErr) return } log.Printf("档口入库码后台批次中断 batch_id=%s needs_check=%d released=%d err=%v", batchID, needsCheck, released, runErr) }() client, message := h.innerCodeSybClient() if message != "" { runErr = errors.New(message) return } result, err := service.RunInnerCodeApplyBatch(context.Background(), h.db, client, batchID, actorUserID) runErr = err if err == nil { log.Printf("档口入库码后台批次完成 batch_id=%s processed=%d updated=%d already_filled=%d skipped=%d failed=%d needs_check=%d", batchID, result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck) } }() } func parseInnerCodeIDs(rawIDs []string) []int64 { ids := make([]int64, 0, len(rawIDs)) seen := make(map[int64]bool, len(rawIDs)) for _, raw := range rawIDs { id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil || id <= 0 || seen[id] { continue } seen[id] = true ids = append(ids, id) } return ids } // InnerCodeRecheck 只重新读取未知结果,绝不再次发送删除或写入。 func (h *Handler) InnerCodeRecheck(c *gin.Context) { businessDate, status, keyword := c.PostForm("date"), c.PostForm("status"), c.PostForm("q") pageNumber := service.ParsePage(c.PostForm("page")) id, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("id")), 10, 64) if err != nil || id <= 0 { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, "记录编号无效,没有执行核对。") return } client, message := h.innerCodeSybClient() if message != "" { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message) return } recheckStatus, message, err := service.RecheckInnerCode(c.Request.Context(), h.db, client, id) if err != nil { message = "重新核对失败:" + err.Error() h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message) return } feedbackKind := innerCodeFeedbackSuccess if recheckStatus == model.InnerCodeNeedsCheck { feedbackKind = innerCodeFeedbackError } h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message) } func (h *Handler) innerCodeSybClient() (*syb.Client, string) { cfg, err := config.Load() if err != nil { return nil, "顺运宝配置不可用,没有发送远端请求。" } client, err := syb.New(cfg.Syb.BaseURL) if err != nil { return nil, "顺运宝地址配置有误,没有发送远端请求。" } if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil { if errors.Is(err, service.ErrSybLoginRequired) { return nil, "顺运宝会话已过期,请先到“顺运宝数据”页面登录。" } return nil, "校验顺运宝会话失败,没有发送远端请求。" } return client, "" } func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, feedbackKind, message string) { h.innerCodeRedirectWithBatch(c, businessDate, status, keyword, pageNumber, feedbackKind, message, "") } func (h *Handler) innerCodeRedirectWithBatch(c *gin.Context, businessDate, status, keyword string, pageNumber int, feedbackKind, message, batchID string) { values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}} if message = strings.TrimSpace(message); message != "" { values.Set("feedback", feedbackKind) values.Set("message", message) } values.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) if strings.TrimSpace(status) != "" { values.Set("status", strings.TrimSpace(status)) } if strings.TrimSpace(keyword) != "" { values.Set("q", strings.TrimSpace(keyword)) } if strings.TrimSpace(batchID) != "" { values.Set("apply_batch_id", strings.TrimSpace(batchID)) } c.Redirect(http.StatusSeeOther, "/inner-codes?"+values.Encode()) } func cloneURLValues(source url.Values) url.Values { result := make(url.Values, len(source)) for key, values := range source { result[key] = append([]string(nil), values...) } return result }