feat: 财务结清、酒行信息、库存备注编辑、标签溯源、入库必填校验
后端 - 新增 shop handler:GET/PUT /shop/info(管理员权限) - 新增 finance CloseByRef:按单据 ref_type+ref_id 结清账款 - 新增 inventory UpdateRemark:PUT /inventory/:id/remark - 入库/出库审批自动生成财务应付/应收记录(去除金额>0限制) - 种子数据 S001-S003 补充真实门店信息 前端 - 设置页新增「酒行信息」Tab,管理员可编辑门店名称/地址/电话/负责人 - 入库单列表新增结清按钮(含确认弹窗),出库单同步 - 入库表单:规格、系列、生产日期、供应商、商品名称改为提交必填 - 入库/出库列表新增入库时间、出库时间、创建时间列 - 商品标签标题改为读取 shop 表门店名,扫码文案改为「扫码溯源 · TRACE」 - 标签页脚显示门店地址和电话(从 API 读取,不再依赖编译时 dart-define) - 库存备注支持点击编辑,超4字截断显示+Hover展示全文 - ApiClient 新增 patch() 方法(已改用 PUT 规避 CORS) 文档 - 新增 docs/user-manual.md 完整用户操作手册(12章) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -61,3 +62,145 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
|
||||
"page_size": q.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Create POST /api/v1/finance/records — 手动录入付款/收款
|
||||
func (h *FinanceHandler) Create(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req struct {
|
||||
PartnerID *uint64 `json:"partner_id"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gt=0"`
|
||||
RecordDate string `json:"record_date" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Type != "payment" && req.Type != "receipt" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "type must be payment or receipt"})
|
||||
return
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", req.RecordDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid record_date, use YYYY-MM-DD"})
|
||||
return
|
||||
}
|
||||
|
||||
// 付款/收款 均为减少余额(抵消应付/应收)
|
||||
prevBalance := partnerLastBalance(h.db, shopID, req.PartnerID)
|
||||
bal := prevBalance - req.Amount
|
||||
|
||||
rec := model.FinanceRecord{
|
||||
ShopID: shopID,
|
||||
PartnerID: req.PartnerID,
|
||||
Type: req.Type,
|
||||
Amount: req.Amount,
|
||||
Balance: bal,
|
||||
Status: "closed",
|
||||
OperatorID: userID,
|
||||
RecordDate: date,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if err := h.db.Create(&rec).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rec)
|
||||
}
|
||||
|
||||
// Close PUT /api/v1/finance/records/:id/close — 标记结清
|
||||
func (h *FinanceHandler) Close(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var rec model.FinanceRecord
|
||||
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||
First(&rec).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "record not found"})
|
||||
return
|
||||
}
|
||||
if rec.Type != "payable" && rec.Type != "receivable" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only payable/receivable can be closed"})
|
||||
return
|
||||
}
|
||||
if rec.Status == "closed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already closed"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&rec).Update("status", "closed").Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// CloseByRef PUT /api/v1/finance/records/close-by-ref?ref_type=stock_in&ref_id=123
|
||||
func (h *FinanceHandler) CloseByRef(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
refType := c.Query("ref_type")
|
||||
refID := c.Query("ref_id")
|
||||
if refType == "" || refID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ref_type and ref_id are required"})
|
||||
return
|
||||
}
|
||||
|
||||
var rec model.FinanceRecord
|
||||
err := h.db.Where("shop_id = ? AND ref_type = ? AND ref_id = ? AND status = 'open' AND deleted_at IS NULL",
|
||||
shopID, refType, refID).First(&rec).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no open finance record found"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&rec).Update("status", "closed").Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Summary GET /api/v1/finance/summary — 按往来单位汇总未结清
|
||||
func (h *FinanceHandler) Summary(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
type row struct {
|
||||
PartnerID *uint64 `json:"partner_id"`
|
||||
PartnerName string `json:"partner_name"`
|
||||
Type string `json:"type"`
|
||||
RecordCount int `json:"record_count"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
}
|
||||
var rows []row
|
||||
h.db.Raw(`
|
||||
SELECT f.partner_id,
|
||||
COALESCE(p.name, '') AS partner_name,
|
||||
f.type,
|
||||
COUNT(*) AS record_count,
|
||||
SUM(f.amount) AS total_amount
|
||||
FROM finance_records f
|
||||
LEFT JOIN partners p ON p.id = f.partner_id
|
||||
WHERE f.shop_id = ? AND f.deleted_at IS NULL
|
||||
AND f.type IN ('payable','receivable')
|
||||
AND f.status = 'open'
|
||||
GROUP BY f.partner_id, f.type
|
||||
ORDER BY total_amount DESC
|
||||
`, shopID).Scan(&rows)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows})
|
||||
}
|
||||
|
||||
// partnerLastBalance 查询该往来单位最后一条财务记录的余额
|
||||
func partnerLastBalance(db *gorm.DB, shopID uint64, partnerID *uint64) float64 {
|
||||
var last model.FinanceRecord
|
||||
q := db.Where("shop_id = ? AND deleted_at IS NULL", shopID)
|
||||
if partnerID != nil {
|
||||
q = q.Where("partner_id = ?", *partnerID)
|
||||
} else {
|
||||
q = q.Where("partner_id IS NULL")
|
||||
}
|
||||
q.Order("id DESC").First(&last)
|
||||
return last.Balance
|
||||
}
|
||||
|
||||
@@ -98,6 +98,37 @@ func (h *ImportHandler) ImportProducts(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ImportProductCodes POST /api/v1/import/product-codes
|
||||
// 列顺序:商品名称,商品编码(按名称匹配商品并更新编码)
|
||||
func (h *ImportHandler) ImportProductCodes(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
rows, err := parseUploadedExcel(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, imported, skipped := 0, 0, 0
|
||||
for _, row := range rows[1:] {
|
||||
name := cell(row, 0)
|
||||
code := cell(row, 1)
|
||||
if name == "" || code == "" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
var p model.Product
|
||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&p).Error != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if h.db.Model(&p).Update("code", code).Error == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "imported": imported, "skipped": skipped})
|
||||
}
|
||||
|
||||
// ImportPartners POST /api/v1/import/partners
|
||||
// 列顺序(来往单位.xls):编号,类型,状态,名称,电话,卡号,初始金额,单位,地址,...,备注
|
||||
func (h *ImportHandler) ImportPartners(c *gin.Context) {
|
||||
@@ -319,7 +350,7 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) {
|
||||
price, _ := strconv.ParseFloat(cell(row, 11), 64)
|
||||
batchNo := cell(row, 16)
|
||||
|
||||
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
|
||||
prod, err := findOrCreateProductFn(h.db, shopID, "", productName, series, spec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -422,7 +453,7 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) {
|
||||
qty, _ := strconv.ParseFloat(cell(row, 9), 64)
|
||||
price, _ := strconv.ParseFloat(cell(row, 11), 64)
|
||||
|
||||
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
|
||||
prod, err := findOrCreateProductFn(h.db, shopID, "", productName, series, spec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -480,34 +511,54 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
type result struct {
|
||||
type importResult struct {
|
||||
imported int
|
||||
updated int
|
||||
skipped int
|
||||
errors []string
|
||||
}
|
||||
var res result
|
||||
var res importResult
|
||||
|
||||
// 仓库缓存,避免重复查询
|
||||
warehouseCache := map[string]uint64{}
|
||||
findOrCreateWarehouse := func(name string) (uint64, error) {
|
||||
// 仓库缓存,只查找不创建
|
||||
warehouseCache := map[string]*uint64{}
|
||||
findWarehouse := func(name string) *uint64 {
|
||||
if name == "" {
|
||||
name = "默认仓库"
|
||||
return nil
|
||||
}
|
||||
if id, ok := warehouseCache[name]; ok {
|
||||
return id, nil
|
||||
if idPtr, ok := warehouseCache[name]; ok {
|
||||
return idPtr
|
||||
}
|
||||
var wh model.Warehouse
|
||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
|
||||
wh = model.Warehouse{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
Name: name,
|
||||
}
|
||||
if err := h.db.Create(&wh).Error; err != nil {
|
||||
return 0, err
|
||||
warehouseCache[name] = nil
|
||||
return nil
|
||||
}
|
||||
id := wh.ID
|
||||
warehouseCache[name] = &id
|
||||
return &id
|
||||
}
|
||||
|
||||
// Dynamic column detection from header row
|
||||
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
|
||||
if len(rows) > 0 {
|
||||
for j, h := range rows[0] {
|
||||
switch strings.TrimSpace(h) {
|
||||
case "库存数量", "数量":
|
||||
colQty = j
|
||||
case "单价":
|
||||
colPrice = j
|
||||
case "生产日期":
|
||||
colProductionDate = j
|
||||
case "批次", "批次号":
|
||||
colBatchNo = j
|
||||
case "所在仓库", "仓库":
|
||||
colWarehouse = j
|
||||
case "供应商":
|
||||
colSupplier = j
|
||||
case "备注":
|
||||
colRemark = j
|
||||
}
|
||||
}
|
||||
warehouseCache[name] = wh.ID
|
||||
return wh.ID, nil
|
||||
}
|
||||
|
||||
for i, row := range rows[1:] {
|
||||
@@ -516,82 +567,148 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
||||
res.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
productCode := cell(row, 0)
|
||||
series := cell(row, 2)
|
||||
spec := cell(row, 3)
|
||||
unit := cell(row, 4)
|
||||
qtyStr := cell(row, 5)
|
||||
priceStr := cell(row, 6)
|
||||
warehouseName := cell(row, 11)
|
||||
qtyStr := cell(row, colQty)
|
||||
priceStr := cell(row, colPrice)
|
||||
productionDateStr := cell(row, colProductionDate)
|
||||
batchNo := cell(row, colBatchNo)
|
||||
warehouseName := cell(row, colWarehouse)
|
||||
supplierName := cell(row, colSupplier)
|
||||
remark := cell(row, colRemark)
|
||||
|
||||
qty, _ := strconv.ParseFloat(qtyStr, 64)
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
price, _ := strconv.ParseFloat(priceStr, 64)
|
||||
|
||||
// 找或创建商品
|
||||
prod, err := findOrCreateProductFn(h.db, shopID, productName, series, spec)
|
||||
if err != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, err.Error()))
|
||||
continue
|
||||
// 只查找商品,不强制创建
|
||||
var prod model.Product
|
||||
if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))",
|
||||
shopID, productCode, productName, series, spec).First(&prod).Error != nil {
|
||||
// 若找不到则创建
|
||||
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
|
||||
if createErr != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
|
||||
continue
|
||||
}
|
||||
prod = newProd
|
||||
}
|
||||
if unit != "" && prod.Unit == "" {
|
||||
h.db.Model(&prod).Update("unit", unit)
|
||||
}
|
||||
if price > 0 && prod.PurchasePrice == 0 {
|
||||
h.db.Model(&prod).Update("purchase_price", price)
|
||||
|
||||
// 解析生产日期
|
||||
var productionDate *model.Date
|
||||
if productionDateStr != "" {
|
||||
d := parseDate(productionDateStr)
|
||||
productionDate = &d
|
||||
}
|
||||
|
||||
// 找或创建仓库
|
||||
whID, err := findOrCreateWarehouse(warehouseName)
|
||||
if err != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 仓库创建失败: %s", i+2, err.Error()))
|
||||
continue
|
||||
// 查找仓库(只查,不创建)
|
||||
whIDPtr := findWarehouse(warehouseName)
|
||||
|
||||
var unitPricePtr *float64
|
||||
if price != 0 {
|
||||
unitPricePtr = &price
|
||||
}
|
||||
|
||||
// upsert 库存数量
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var inv model.Inventory
|
||||
isNew := false
|
||||
if tx.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, whID, prod.ID).First(&inv).Error != nil {
|
||||
inv = model.Inventory{
|
||||
ShopID: shopID,
|
||||
WarehouseID: whID,
|
||||
ProductID: prod.ID,
|
||||
}
|
||||
isNew = true
|
||||
}
|
||||
qtyBefore := inv.Quantity
|
||||
inv.Quantity = qty
|
||||
if isNew {
|
||||
if err := tx.Create(&inv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Save(&inv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 写流水
|
||||
log := model.InventoryLog{
|
||||
ShopID: shopID,
|
||||
WarehouseID: whID,
|
||||
ProductID: prod.ID,
|
||||
Direction: "in",
|
||||
Quantity: qty,
|
||||
QtyBefore: qtyBefore,
|
||||
QtyAfter: qty,
|
||||
RefType: "import",
|
||||
}
|
||||
return tx.Create(&log).Error
|
||||
})
|
||||
if err != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
||||
continue
|
||||
productIDCopy := prod.ID
|
||||
|
||||
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
|
||||
var existing model.Inventory
|
||||
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
|
||||
shopID, prod.Code)
|
||||
if whIDPtr != nil {
|
||||
q = q.Where("warehouse_id = ?", *whIDPtr)
|
||||
} else {
|
||||
q = q.Where("warehouse_id IS NULL")
|
||||
}
|
||||
res.imported++
|
||||
found := q.First(&existing).Error == nil
|
||||
|
||||
if found {
|
||||
updates := map[string]interface{}{
|
||||
"quantity": qty,
|
||||
"product_name": prod.Name,
|
||||
"series": prod.Series,
|
||||
"spec": prod.Spec,
|
||||
"unit": prod.Unit,
|
||||
"warehouse_name": warehouseName,
|
||||
"supplier_name": supplierName,
|
||||
"remark": remark,
|
||||
"deleted_at": nil,
|
||||
}
|
||||
if unitPricePtr != nil {
|
||||
updates["unit_price"] = *unitPricePtr
|
||||
}
|
||||
if productionDate != nil {
|
||||
updates["production_date"] = productionDate
|
||||
}
|
||||
if batchNo != "" {
|
||||
updates["batch_no"] = batchNo
|
||||
}
|
||||
if err := h.db.Model(&existing).Updates(updates).Error; err != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
inv := model.Inventory{
|
||||
ShopID: shopID,
|
||||
WarehouseID: whIDPtr,
|
||||
ProductID: &productIDCopy,
|
||||
StockInItemID: nil,
|
||||
Quantity: qty,
|
||||
ProductCode: prod.Code,
|
||||
ProductName: prod.Name,
|
||||
Series: prod.Series,
|
||||
Spec: prod.Spec,
|
||||
Unit: prod.Unit,
|
||||
WarehouseName: warehouseName,
|
||||
UnitPrice: unitPricePtr,
|
||||
ProductionDate: productionDate,
|
||||
BatchNo: batchNo,
|
||||
SupplierName: supplierName,
|
||||
Remark: remark,
|
||||
}
|
||||
if err := h.db.Create(&inv).Error; err != nil {
|
||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 写流水
|
||||
warehouseID := uint64(0)
|
||||
if whIDPtr != nil {
|
||||
warehouseID = *whIDPtr
|
||||
}
|
||||
qtyBefore := 0.0
|
||||
if found {
|
||||
qtyBefore = existing.Quantity
|
||||
res.updated++
|
||||
} else {
|
||||
res.imported++
|
||||
}
|
||||
log := model.InventoryLog{
|
||||
ShopID: shopID,
|
||||
WarehouseID: warehouseID,
|
||||
ProductID: prod.ID,
|
||||
Direction: "in",
|
||||
Quantity: qty,
|
||||
QtyBefore: qtyBefore,
|
||||
QtyAfter: qty,
|
||||
RefType: "import",
|
||||
RefID: 0,
|
||||
}
|
||||
h.db.Create(&log)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"imported": res.imported,
|
||||
"updated": res.updated,
|
||||
"skipped": res.skipped,
|
||||
"errors": res.errors,
|
||||
})
|
||||
@@ -657,13 +774,15 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
|
||||
const maxEmpty = 5
|
||||
emptyStreak := 0
|
||||
for r := 0; r < maxRows; r++ {
|
||||
row := sheet.Row(r)
|
||||
row := safeXlsRow(sheet, r)
|
||||
cells := make([]string, numCols)
|
||||
isEmpty := true
|
||||
for c := 0; c < numCols; c++ {
|
||||
cells[c] = strings.TrimSpace(row.Col(c))
|
||||
if cells[c] != "" {
|
||||
isEmpty = false
|
||||
if row != nil {
|
||||
for c := 0; c < numCols; c++ {
|
||||
cells[c] = strings.TrimSpace(row.Col(c))
|
||||
if cells[c] != "" {
|
||||
isEmpty = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if isEmpty {
|
||||
@@ -696,15 +815,21 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func findOrCreateProductFn(db *gorm.DB, shopID uint64, name, series, spec string) (model.Product, error) {
|
||||
func findOrCreateProductFn(db *gorm.DB, shopID uint64, code, name, series, spec string) (model.Product, error) {
|
||||
var p model.Product
|
||||
if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
|
||||
shopID, name, series, spec).First(&p).Error == nil {
|
||||
// 若现有商品没有编码,补填
|
||||
if p.Code == "" && code != "" {
|
||||
db.Model(&p).Update("code", code)
|
||||
p.Code = code
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
p = model.Product{
|
||||
TenantBase: model.TenantBase{ShopID: shopID},
|
||||
PublicID: uuid.New().String(),
|
||||
Code: code,
|
||||
Name: name,
|
||||
Series: series,
|
||||
Spec: spec,
|
||||
@@ -762,3 +887,9 @@ func cell(row []string, idx int) string {
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
// safeXlsRow 安全读取 XLS 行,捕获 extrame/xls 在超出行数时的 panic。
|
||||
func safeXlsRow(sheet *xls.WorkSheet, r int) (row *xls.Row) {
|
||||
defer func() { recover() }() //nolint:errcheck
|
||||
return sheet.Row(r)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -20,33 +20,103 @@ func NewInventoryHandler(db *gorm.DB) *InventoryHandler {
|
||||
return &InventoryHandler{db: db}
|
||||
}
|
||||
|
||||
// inventoryRow is the response shape for GET /api/v1/inventory
|
||||
type inventoryRow struct {
|
||||
ID uint64 `json:"id"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
WarehouseID *uint64 `json:"warehouse_id"`
|
||||
ProductID *uint64 `json:"product_id"`
|
||||
StockInItemID *uint64 `json:"stock_in_item_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
ProductCode string `json:"product_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
Series string `json:"series"`
|
||||
Spec string `json:"spec"`
|
||||
Unit string `json:"unit"`
|
||||
WarehouseName string `json:"warehouse_name"`
|
||||
UnitPrice *float64 `json:"unit_price"`
|
||||
ProductionDate *string `json:"production_date"`
|
||||
BatchNo string `json:"batch_no"`
|
||||
SupplierName string `json:"supplier_name"`
|
||||
Remark string `json:"remark"`
|
||||
Brand string `json:"brand"`
|
||||
MinStock *int `json:"min_stock"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// List GET /api/v1/inventory
|
||||
func (h *InventoryHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 500 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := h.db.Model(&model.Inventory{}).Where("shop_id = ?", shopID)
|
||||
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
|
||||
args := []interface{}{shopID}
|
||||
|
||||
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
|
||||
query = query.Where("warehouse_id = ?", warehouseID)
|
||||
keyword := c.Query("keyword")
|
||||
warehouseIDStr := c.Query("warehouse_id")
|
||||
|
||||
if keyword != "" {
|
||||
baseWhere += " AND (COALESCE(NULLIF(p.name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), inv.product_code) LIKE ?)"
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if productID := c.Query("product_id"); productID != "" {
|
||||
query = query.Where("product_id = ?", productID)
|
||||
}
|
||||
if c.Query("in_stock") == "1" {
|
||||
query = query.Where("quantity > 0")
|
||||
if warehouseIDStr != "" {
|
||||
baseWhere += " AND inv.warehouse_id = ?"
|
||||
args = append(args, warehouseIDStr)
|
||||
}
|
||||
|
||||
// Count query
|
||||
countSQL := `
|
||||
SELECT COUNT(*)
|
||||
FROM inventories inv
|
||||
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
|
||||
LEFT JOIN products p ON p.id = inv.product_id
|
||||
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE ` + baseWhere
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
h.db.Raw(countSQL, args...).Scan(&total)
|
||||
|
||||
var inventory []model.Inventory
|
||||
query.Preload("Product").Preload("Warehouse").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Find(&inventory)
|
||||
// Data query
|
||||
dataSQL := `
|
||||
SELECT
|
||||
inv.id, inv.shop_id, inv.warehouse_id, inv.product_id, inv.stock_in_item_id,
|
||||
inv.quantity,
|
||||
COALESCE(NULLIF(p.code,''), inv.product_code, '') AS product_code,
|
||||
COALESCE(NULLIF(p.name,''), inv.product_name, '') AS product_name,
|
||||
COALESCE(NULLIF(p.series,''), inv.series, '') AS series,
|
||||
COALESCE(NULLIF(p.spec,''), inv.spec, '') AS spec,
|
||||
COALESCE(NULLIF(p.unit,''), inv.unit, '') AS unit,
|
||||
COALESCE(NULLIF(w.name,''), inv.warehouse_name, '') AS warehouse_name,
|
||||
COALESCE(sii.unit_price, inv.unit_price) AS unit_price,
|
||||
COALESCE(DATE_FORMAT(sii.production_date,'%Y-%m-%d'), DATE_FORMAT(inv.production_date,'%Y-%m-%d')) AS production_date,
|
||||
COALESCE(NULLIF(sii.batch_no,''), inv.batch_no, '') AS batch_no,
|
||||
inv.supplier_name,
|
||||
inv.remark,
|
||||
COALESCE(p.brand, '') AS brand,
|
||||
p.min_stock,
|
||||
DATE_FORMAT(inv.created_at, '%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM inventories inv
|
||||
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
|
||||
LEFT JOIN products p ON p.id = inv.product_id
|
||||
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
|
||||
WHERE ` + baseWhere + `
|
||||
ORDER BY inv.id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": inventory, "total": total, "page": page, "page_size": pageSize})
|
||||
dataArgs := append(args, pageSize, (page-1)*pageSize)
|
||||
|
||||
var rows []inventoryRow
|
||||
h.db.Raw(dataSQL, dataArgs...).Scan(&rows)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// Logs GET /api/v1/inventory/logs
|
||||
@@ -70,115 +140,6 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// productTrackingItem is the response shape for GET /api/v1/inventory/products
|
||||
type productTrackingItem struct {
|
||||
model.StockInItem
|
||||
CurrentQty float64 `json:"current_qty"`
|
||||
Status string `json:"status"` // in_stock | sold_out
|
||||
BuyerName string `json:"buyer_name,omitempty"`
|
||||
SoldAt string `json:"sold_at,omitempty"`
|
||||
}
|
||||
|
||||
// Products GET /api/v1/inventory/products — 商品追踪:已审核入库单的明细行,附库存状态和买家
|
||||
func (h *InventoryHandler) Products(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
query := h.db.Model(&model.StockInItem{}).
|
||||
Joins("JOIN stock_in_orders ON stock_in_orders.id = stock_in_items.order_id").
|
||||
Where("stock_in_orders.shop_id = ? AND stock_in_orders.status = 'approved'", shopID)
|
||||
|
||||
if productID := c.Query("product_id"); productID != "" {
|
||||
query = query.Where("stock_in_items.product_id = ?", productID)
|
||||
}
|
||||
if warehouseID := c.Query("warehouse_id"); warehouseID != "" {
|
||||
query = query.Where("stock_in_orders.warehouse_id = ?", warehouseID)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []model.StockInItem
|
||||
query.
|
||||
Preload("Product").
|
||||
Preload("Order", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Preload("Warehouse").Preload("Partner")
|
||||
}).
|
||||
Select("stock_in_items.*").
|
||||
Order("stock_in_orders.order_date DESC, stock_in_items.id DESC").
|
||||
Offset((page-1)*pageSize).Limit(pageSize).
|
||||
Find(&items)
|
||||
|
||||
// 一次查出所有库存,构建 product+warehouse → qty 的 map
|
||||
var inventories []model.Inventory
|
||||
h.db.Where("shop_id = ?", shopID).Find(&inventories)
|
||||
invMap := make(map[string]float64, len(inventories))
|
||||
for _, inv := range inventories {
|
||||
key := fmt.Sprintf("%d:%d", inv.ProductID, inv.WarehouseID)
|
||||
invMap[key] = inv.Quantity
|
||||
}
|
||||
|
||||
// 查询每个 product+warehouse 最新的出库买家信息
|
||||
type soldRow struct {
|
||||
ProductID uint64 `gorm:"column:product_id"`
|
||||
WarehouseID uint64 `gorm:"column:warehouse_id"`
|
||||
BuyerName string `gorm:"column:buyer_name"`
|
||||
SoldAt string `gorm:"column:sold_at"`
|
||||
}
|
||||
var soldRows []soldRow
|
||||
h.db.Raw(`
|
||||
SELECT sooi.product_id, soo.warehouse_id,
|
||||
COALESCE(p.name, '') AS buyer_name,
|
||||
CAST(MAX(soo.order_date) AS CHAR) AS sold_at
|
||||
FROM stock_out_orders soo
|
||||
JOIN stock_out_items sooi ON sooi.order_id = soo.id
|
||||
LEFT JOIN partners p ON p.id = soo.partner_id AND p.shop_id = ?
|
||||
WHERE soo.shop_id = ? AND soo.status = 'approved'
|
||||
GROUP BY sooi.product_id, soo.warehouse_id
|
||||
`, shopID, shopID).Scan(&soldRows)
|
||||
soldMap := make(map[string]soldRow, len(soldRows))
|
||||
for _, r := range soldRows {
|
||||
key := fmt.Sprintf("%d:%d", r.ProductID, r.WarehouseID)
|
||||
soldMap[key] = r
|
||||
}
|
||||
|
||||
result := make([]productTrackingItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
var warehouseID uint64
|
||||
if item.Order != nil {
|
||||
warehouseID = item.Order.WarehouseID
|
||||
}
|
||||
key := fmt.Sprintf("%d:%d", item.ProductID, warehouseID)
|
||||
currentQty := invMap[key]
|
||||
|
||||
status := "in_stock"
|
||||
buyerName := ""
|
||||
soldAt := ""
|
||||
if currentQty <= 0 {
|
||||
status = "sold_out"
|
||||
if si, ok := soldMap[key]; ok {
|
||||
buyerName = si.BuyerName
|
||||
if len(si.SoldAt) > 10 {
|
||||
soldAt = si.SoldAt[:10]
|
||||
} else {
|
||||
soldAt = si.SoldAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, productTrackingItem{
|
||||
StockInItem: item,
|
||||
CurrentQty: currentQty,
|
||||
Status: status,
|
||||
BuyerName: buyerName,
|
||||
SoldAt: soldAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// CreateCheck POST /api/v1/inventory/checks
|
||||
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
@@ -194,14 +155,17 @@ func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
||||
req.OperatorID = operatorID
|
||||
req.Status = "draft"
|
||||
|
||||
// 自动填入系统库存数量
|
||||
// 自动填入系统库存数量(SUM 聚合)
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
var inv model.Inventory
|
||||
if err := h.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, req.WarehouseID, req.Items[i].ProductID).First(&inv).Error; err == nil {
|
||||
req.Items[i].SystemQty = inv.Quantity
|
||||
}
|
||||
warehouseID := req.WarehouseID
|
||||
productID := req.Items[i].ProductID
|
||||
var systemQty float64
|
||||
h.db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
|
||||
shopID, warehouseID, productID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&systemQty)
|
||||
req.Items[i].SystemQty = systemQty
|
||||
}
|
||||
|
||||
if err := h.db.Create(&req).Error; err != nil {
|
||||
@@ -223,3 +187,122 @@ func (h *InventoryHandler) GetCheck(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": check})
|
||||
}
|
||||
|
||||
// CompleteCheck PUT /api/v1/inventory/checks/:id/complete
|
||||
func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
checkID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
var check model.InventoryCheck
|
||||
if err := h.db.Preload("Items.Product").
|
||||
Where("id = ? AND shop_id = ?", checkID, shopID).
|
||||
First(&check).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if check.Status == "completed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "已完成"})
|
||||
return
|
||||
}
|
||||
|
||||
tx := h.db.Begin()
|
||||
now := time.Now()
|
||||
|
||||
for _, item := range check.Items {
|
||||
diff := item.ActualQty - item.SystemQty
|
||||
if diff == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if diff > 0 {
|
||||
// 盘盈:新增一条库存批次记录
|
||||
checkIDCopy := checkID
|
||||
productIDCopy := item.ProductID
|
||||
warehouseIDCopy := check.WarehouseID
|
||||
|
||||
productCode := ""
|
||||
productName := ""
|
||||
series := ""
|
||||
spec := ""
|
||||
unit := ""
|
||||
if item.Product != nil {
|
||||
productCode = item.Product.Code
|
||||
productName = item.Product.Name
|
||||
series = item.Product.Series
|
||||
spec = item.Product.Spec
|
||||
unit = item.Product.Unit
|
||||
}
|
||||
|
||||
inv := model.Inventory{
|
||||
ShopID: shopID,
|
||||
WarehouseID: &warehouseIDCopy,
|
||||
ProductID: &productIDCopy,
|
||||
InventoryCheckID: &checkIDCopy,
|
||||
Quantity: diff,
|
||||
ProductCode: productCode,
|
||||
ProductName: productName,
|
||||
Series: series,
|
||||
Spec: spec,
|
||||
Unit: unit,
|
||||
}
|
||||
tx.Create(&inv)
|
||||
} else {
|
||||
// 盘亏:FIFO 扣减
|
||||
remaining := -diff
|
||||
var batches []model.Inventory
|
||||
tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
|
||||
shopID, check.WarehouseID, item.ProductID).
|
||||
Order("created_at ASC").Find(&batches)
|
||||
for i := range batches {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
b := &batches[i]
|
||||
if b.Quantity <= remaining {
|
||||
remaining -= b.Quantity
|
||||
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||||
} else {
|
||||
tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Model(&check).Update("status", "completed")
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "盘点完成"})
|
||||
}
|
||||
|
||||
// UpdateRemark PUT /api/v1/inventory/:id/remark
|
||||
func (h *InventoryHandler) UpdateRemark(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
result := h.db.Model(&model.Inventory{}).
|
||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||
Update("remark", req.Remark)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "库存记录不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -18,11 +18,31 @@ func NewNumberRuleHandler(db *gorm.DB) *NumberRuleHandler {
|
||||
return &NumberRuleHandler{db: db}
|
||||
}
|
||||
|
||||
var defaultRules = []struct {
|
||||
Type string
|
||||
Prefix string
|
||||
DateFormat string
|
||||
}{
|
||||
{"stock_in", "RK", "YYYYMMDD"},
|
||||
{"stock_out", "CK", "YYYYMMDD"},
|
||||
{"inventory_check", "PD", "YYYYMMDD"},
|
||||
{"product", "SP", "YYYYMMDD"},
|
||||
}
|
||||
|
||||
// List GET /api/v1/number-rules
|
||||
func (h *NumberRuleHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var rules []model.NumberRule
|
||||
h.db.Where("shop_id = ?", shopID).Order("id").Find(&rules)
|
||||
|
||||
if len(rules) == 0 {
|
||||
for _, d := range defaultRules {
|
||||
r := model.NumberRule{ShopID: shopID, Type: d.Type, Prefix: d.Prefix, DateFormat: d.DateFormat}
|
||||
h.db.Create(&r)
|
||||
rules = append(rules, r)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": rules})
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
|
||||
|
||||
var product model.Product
|
||||
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||
Select("id, public_id").First(&product).Error; err != nil {
|
||||
Select("id, public_id, code").First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
@@ -188,6 +188,9 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
url := config.C.Storage.PublicURL + "/product/" + product.PublicID
|
||||
if product.Code != "" {
|
||||
url += "?code=" + product.Code
|
||||
}
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -51,6 +51,29 @@ func (h *ProductOptionHandler) CreateName(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) UpdateName(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item model.ProductNameOption
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
item.Code = req.Code
|
||||
item.Name = req.Name
|
||||
item.Remark = req.Remark
|
||||
h.db.Save(&item)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductNameOption{})
|
||||
@@ -90,6 +113,29 @@ func (h *ProductOptionHandler) CreateSeries(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) UpdateSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item model.ProductSeriesOption
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
item.Code = req.Code
|
||||
item.Name = req.Name
|
||||
item.Remark = req.Remark
|
||||
h.db.Save(&item)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSeriesOption{})
|
||||
@@ -131,6 +177,31 @@ func (h *ProductOptionHandler) CreateSpec(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) UpdateSpec(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Quantity int `json:"quantity"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item model.ProductSpecOption
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
item.Code = req.Code
|
||||
item.Name = req.Name
|
||||
item.Quantity = req.Quantity
|
||||
item.Remark = req.Remark
|
||||
h.db.Save(&item)
|
||||
c.JSON(http.StatusOK, gin.H{"data": item})
|
||||
}
|
||||
|
||||
func (h *ProductOptionHandler) DeleteSpec(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductSpecOption{})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type ShopHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewShopHandler(db *gorm.DB) *ShopHandler {
|
||||
return &ShopHandler{db: db}
|
||||
}
|
||||
|
||||
// GetInfo GET /api/v1/shop/info
|
||||
func (h *ShopHandler) GetInfo(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var shop model.Shop
|
||||
if err := h.db.First(&shop, shopID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "shop not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, shop)
|
||||
}
|
||||
|
||||
// UpdateInfo PUT /api/v1/shop/info (admin only)
|
||||
func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Phone string `json:"phone"`
|
||||
ManagerName string `json:"manager_name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"name": req.Name,
|
||||
"address": req.Address,
|
||||
"phone": req.Phone,
|
||||
"manager_name": req.ManagerName,
|
||||
}
|
||||
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var shop model.Shop
|
||||
h.db.First(&shop, shopID)
|
||||
c.JSON(http.StatusOK, shop)
|
||||
}
|
||||
@@ -65,25 +65,46 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": order})
|
||||
}
|
||||
|
||||
// checkInventory validates that warehouse has enough stock for each item.
|
||||
// checkInventory validates that warehouse has enough stock for each item (SUM aggregate).
|
||||
// warehouseID is the order's warehouse; items are the stock-out line items.
|
||||
func (h *StockOutHandler) checkInventory(shopID, warehouseID uint64, items []model.StockOutItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect product IDs
|
||||
productIDs := make([]uint64, 0, len(items))
|
||||
for _, item := range items {
|
||||
var inv model.Inventory
|
||||
err := h.db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, warehouseID, item.ProductID).First(&inv).Error
|
||||
if err != nil || inv.Quantity < item.Quantity {
|
||||
// Try to get product name for a clearer error message
|
||||
productIDs = append(productIDs, item.ProductID)
|
||||
}
|
||||
|
||||
type inventorySum struct {
|
||||
ProductID uint64
|
||||
Total float64
|
||||
}
|
||||
var sums []inventorySum
|
||||
h.db.Model(&model.Inventory{}).
|
||||
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
|
||||
shopID, warehouseID, productIDs).
|
||||
Group("product_id").Scan(&sums)
|
||||
|
||||
// Build map for quick lookup
|
||||
sumMap := make(map[uint64]float64, len(sums))
|
||||
for _, s := range sums {
|
||||
sumMap[s.ProductID] = s.Total
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
have := sumMap[item.ProductID]
|
||||
if have < item.Quantity {
|
||||
// Get product name for a clearer error message
|
||||
var p model.Product
|
||||
h.db.Where("id = ?", item.ProductID).First(&p)
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("商品ID %d", item.ProductID)
|
||||
}
|
||||
have := 0.0
|
||||
if err == nil {
|
||||
have = inv.Quantity
|
||||
}
|
||||
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user