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:
+44
-16
@@ -666,37 +666,65 @@ func createStockOutOrder(
|
||||
|
||||
func updateInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockInResult) {
|
||||
for _, it := range r.items {
|
||||
var inv model.Inventory
|
||||
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", shopID, whID, it.productID).First(&inv)
|
||||
before := inv.Quantity
|
||||
after := before + it.qty
|
||||
if inv.ID == 0 {
|
||||
inv = model.Inventory{ShopID: shopID, WarehouseID: whID, ProductID: it.productID, Quantity: after}
|
||||
db.Create(&inv)
|
||||
} else {
|
||||
db.Model(&inv).Update("quantity", after)
|
||||
var qtyBefore float64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
productIDCopy := it.productID
|
||||
whIDCopy := whID
|
||||
inv := model.Inventory{
|
||||
ShopID: shopID,
|
||||
WarehouseID: &whIDCopy,
|
||||
ProductID: &productIDCopy,
|
||||
Quantity: it.qty,
|
||||
}
|
||||
db.Create(&inv)
|
||||
|
||||
db.Create(&model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
|
||||
Direction: "in", Quantity: it.qty, QtyBefore: before, QtyAfter: after,
|
||||
Direction: "in", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.qty,
|
||||
RefType: "stock_in", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func deductInventoryBatch(db *gorm.DB, shopID, whID, opID uint64, r stockOutResult) {
|
||||
now := time.Now()
|
||||
for _, it := range r.items {
|
||||
var inv model.Inventory
|
||||
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ?", shopID, whID, it.productID).First(&inv)
|
||||
before := inv.Quantity
|
||||
after := before - it.qty
|
||||
var qtyBefore float64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL", shopID, whID, it.productID).
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
// FIFO deduction
|
||||
var batches []model.Inventory
|
||||
db.Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
|
||||
shopID, whID, it.productID).
|
||||
Order("created_at ASC").Find(&batches)
|
||||
|
||||
remaining := it.qty
|
||||
for i := range batches {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
b := &batches[i]
|
||||
if b.Quantity <= remaining {
|
||||
remaining -= b.Quantity
|
||||
db.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||||
} else {
|
||||
db.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
after := qtyBefore - it.qty
|
||||
if after < 0 {
|
||||
after = 0
|
||||
}
|
||||
db.Model(&inv).Update("quantity", after)
|
||||
db.Create(&model.InventoryLog{
|
||||
ShopID: shopID, WarehouseID: whID, ProductID: it.productID,
|
||||
Direction: "out", Quantity: it.qty, QtyBefore: before, QtyAfter: after,
|
||||
Direction: "out", Quantity: it.qty, QtyBefore: qtyBefore, QtyAfter: after,
|
||||
RefType: "stock_out", RefID: r.order.ID, OperatorID: &opID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type FinanceRecord struct {
|
||||
Type string `gorm:"type:enum('receivable','payable','receipt','payment')" json:"type"`
|
||||
Amount float64 `gorm:"type:decimal(16,2)" json:"amount"`
|
||||
Balance float64 `gorm:"type:decimal(16,2)" json:"balance"`
|
||||
Status string `gorm:"size:10;default:open" json:"status"`
|
||||
RefType string `gorm:"size:30" json:"ref_type"`
|
||||
RefID *uint64 `json:"ref_id"`
|
||||
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
||||
|
||||
@@ -34,8 +34,9 @@ type StockInItem struct {
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:255" json:"remark"`
|
||||
|
||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
@@ -80,18 +81,34 @@ type StockOutItem struct {
|
||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
}
|
||||
|
||||
// -------- 实时库存 --------
|
||||
// -------- 实时库存(批次模式:每条记录代表一个批次/批次) --------
|
||||
|
||||
type Inventory struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ShopID uint64 `gorm:"not null;uniqueIndex:uk_shop_wh_product" json:"shop_id"`
|
||||
WarehouseID uint64 `gorm:"not null;uniqueIndex:uk_shop_wh_product" json:"warehouse_id"`
|
||||
ProductID uint64 `gorm:"not null;uniqueIndex:uk_shop_wh_product" json:"product_id"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);default:0" json:"quantity"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ShopID uint64 `gorm:"not null" json:"shop_id"`
|
||||
WarehouseID *uint64 `json:"warehouse_id"`
|
||||
ProductID *uint64 `json:"product_id"`
|
||||
StockInItemID *uint64 `json:"stock_in_item_id"`
|
||||
InventoryCheckID *uint64 `json:"inventory_check_id"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"`
|
||||
ProductCode string `gorm:"size:50" json:"product_code"`
|
||||
ProductName string `gorm:"size:200" json:"product_name"`
|
||||
Series string `gorm:"size:100" json:"series"`
|
||||
Spec string `gorm:"size:100" json:"spec"`
|
||||
Unit string `gorm:"size:20" json:"unit"`
|
||||
WarehouseName string `gorm:"size:100" json:"warehouse_name"`
|
||||
UnitPrice *float64 `gorm:"type:decimal(16,2)" json:"unit_price"`
|
||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||
SupplierName string `gorm:"size:200" json:"supplier_name"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `gorm:"index" json:"-"`
|
||||
|
||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
||||
StockInItem *StockInItem `gorm:"foreignKey:StockInItemID" json:"stock_in_item,omitempty"`
|
||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
||||
}
|
||||
|
||||
// -------- 库存流水 --------
|
||||
|
||||
@@ -32,6 +32,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
numberRuleH := handler.NewNumberRuleHandler(db)
|
||||
publicH := handler.NewPublicHandler(db)
|
||||
adminH := handler.NewAdminHandler(db)
|
||||
shopH := handler.NewShopHandler(db)
|
||||
|
||||
// 健康检查(无需认证,用于前端连通性探测)
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
@@ -132,9 +133,10 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
inventory.GET("", inventoryH.List)
|
||||
inventory.GET("/logs", inventoryH.Logs)
|
||||
inventory.GET("/products", inventoryH.Products)
|
||||
inventory.PUT("/:id/remark", inventoryH.UpdateRemark)
|
||||
inventory.POST("/checks", inventoryH.CreateCheck)
|
||||
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
||||
inventory.PUT("/checks/:id/complete", inventoryH.CompleteCheck)
|
||||
}
|
||||
|
||||
// 用户管理(仅管理员)
|
||||
@@ -150,7 +152,18 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
// 财务
|
||||
finance := api.Group("/finance")
|
||||
{
|
||||
finance.GET("/records", financeH.ListRecords)
|
||||
finance.GET("/records", financeH.ListRecords)
|
||||
finance.POST("/records", financeH.Create)
|
||||
finance.PUT("/records/:id/close", financeH.Close)
|
||||
finance.PUT("/records/close-by-ref", financeH.CloseByRef)
|
||||
finance.GET("/summary", financeH.Summary)
|
||||
}
|
||||
|
||||
// 酒行信息
|
||||
shop := api.Group("/shop")
|
||||
{
|
||||
shop.GET("/info", shopH.GetInfo)
|
||||
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
|
||||
}
|
||||
|
||||
// 编号规则
|
||||
@@ -168,6 +181,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
imp.POST("/product-names", importH.ImportProductNames)
|
||||
imp.POST("/product-series", importH.ImportProductSeries)
|
||||
imp.POST("/product-specs", importH.ImportProductSpecs)
|
||||
imp.POST("/product-codes", importH.ImportProductCodes)
|
||||
imp.POST("/stock-in", importH.ImportStockIn)
|
||||
imp.POST("/stock-out", importH.ImportStockOut)
|
||||
imp.POST("/inventory", importH.ImportInventory)
|
||||
@@ -178,14 +192,17 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
opts.GET("/names", productOptH.ListNames)
|
||||
opts.POST("/names", productOptH.CreateName)
|
||||
opts.PUT("/names/:id", productOptH.UpdateName)
|
||||
opts.DELETE("/names/:id", productOptH.DeleteName)
|
||||
|
||||
opts.GET("/series", productOptH.ListSeries)
|
||||
opts.POST("/series", productOptH.CreateSeries)
|
||||
opts.PUT("/series/:id", productOptH.UpdateSeries)
|
||||
opts.DELETE("/series/:id", productOptH.DeleteSeries)
|
||||
|
||||
opts.GET("/specs", productOptH.ListSpecs)
|
||||
opts.POST("/specs", productOptH.CreateSpec)
|
||||
opts.PUT("/specs/:id", productOptH.UpdateSpec)
|
||||
opts.DELETE("/specs/:id", productOptH.DeleteSpec)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -20,11 +21,11 @@ func NewStockService(db *gorm.DB) *StockService {
|
||||
return &StockService{db: db}
|
||||
}
|
||||
|
||||
// ApproveStockIn 审核入库单,审核通过后更新库存(事务)
|
||||
// ApproveStockIn 审核入库单,每个明细行创建一条独立的批次库存记录
|
||||
func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.StockInOrder
|
||||
if err := tx.Preload("Items").
|
||||
if err := tx.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
|
||||
Where("id = ? AND shop_id = ?", orderID, shopID).
|
||||
First(&order).Error; err != nil {
|
||||
return err
|
||||
@@ -33,10 +34,102 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
return errors.New("order is not in pending status")
|
||||
}
|
||||
|
||||
supplierName := ""
|
||||
if order.Partner != nil {
|
||||
supplierName = order.Partner.Name
|
||||
}
|
||||
warehouseName := ""
|
||||
if order.Warehouse != nil {
|
||||
warehouseName = order.Warehouse.Name
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, item := range order.Items {
|
||||
if err := s.updateInventory(tx, shopID, order.WarehouseID, item.ProductID,
|
||||
"in", item.Quantity, orderID, "stock_in", reviewerID); err != nil {
|
||||
itemCopy := item
|
||||
itemID := itemCopy.ID
|
||||
warehouseID := order.WarehouseID
|
||||
productID := itemCopy.ProductID
|
||||
|
||||
// 计算入库前库存总量(用于流水记录)
|
||||
var qtyBefore float64
|
||||
tx.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(&qtyBefore)
|
||||
|
||||
var unitPricePtr *float64
|
||||
if itemCopy.UnitPrice != 0 {
|
||||
unitPricePtr = &itemCopy.UnitPrice
|
||||
}
|
||||
|
||||
productCode := ""
|
||||
productName := ""
|
||||
series := ""
|
||||
spec := ""
|
||||
unit := ""
|
||||
if itemCopy.Product != nil {
|
||||
productCode = itemCopy.Product.Code
|
||||
productName = itemCopy.Product.Name
|
||||
series = itemCopy.Product.Series
|
||||
spec = itemCopy.Product.Spec
|
||||
unit = itemCopy.Product.Unit
|
||||
}
|
||||
|
||||
inv := model.Inventory{
|
||||
ShopID: shopID,
|
||||
WarehouseID: &warehouseID,
|
||||
ProductID: &productID,
|
||||
StockInItemID: &itemID,
|
||||
Quantity: itemCopy.Quantity,
|
||||
ProductCode: productCode,
|
||||
ProductName: productName,
|
||||
Series: series,
|
||||
Spec: spec,
|
||||
Unit: unit,
|
||||
WarehouseName: warehouseName,
|
||||
UnitPrice: unitPricePtr,
|
||||
ProductionDate: itemCopy.ProductionDate,
|
||||
BatchNo: itemCopy.BatchNo,
|
||||
SupplierName: supplierName,
|
||||
}
|
||||
if err := tx.Create(&inv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log := model.InventoryLog{
|
||||
ShopID: shopID,
|
||||
WarehouseID: warehouseID,
|
||||
ProductID: productID,
|
||||
Direction: "in",
|
||||
Quantity: itemCopy.Quantity,
|
||||
QtyBefore: qtyBefore,
|
||||
QtyAfter: qtyBefore + itemCopy.Quantity,
|
||||
RefType: "stock_in",
|
||||
RefID: orderID,
|
||||
OperatorID: &reviewerID,
|
||||
}
|
||||
if err := tx.Create(&log).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 自动创建应付账款财务记录
|
||||
{
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
|
||||
orderID := order.ID
|
||||
rec := model.FinanceRecord{
|
||||
ShopID: shopID,
|
||||
PartnerID: order.PartnerID,
|
||||
Type: "payable",
|
||||
Amount: order.TotalAmount,
|
||||
Balance: bal,
|
||||
Status: "open",
|
||||
RefType: "stock_in",
|
||||
RefID: &orderID,
|
||||
OperatorID: reviewerID,
|
||||
RecordDate: order.OrderDate.Time,
|
||||
}
|
||||
if err := tx.Create(&rec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -49,7 +142,7 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
})
|
||||
}
|
||||
|
||||
// ApproveStockOut 审核出库单
|
||||
// ApproveStockOut 审核出库单,FIFO 扣减批次库存
|
||||
func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var order model.StockOutOrder
|
||||
@@ -62,24 +155,84 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
return errors.New("order is not in pending status")
|
||||
}
|
||||
|
||||
// 预检库存(FOR UPDATE 加锁,防止并发审核超卖)
|
||||
now := time.Now()
|
||||
warehouseID := order.WarehouseID
|
||||
|
||||
for _, item := range order.Items {
|
||||
var inv model.Inventory
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, order.WarehouseID, item.ProductID).First(&inv).Error; err != nil {
|
||||
return fmt.Errorf("product %d not in inventory", item.ProductID)
|
||||
}
|
||||
if inv.Quantity < item.Quantity {
|
||||
itemCopy := item
|
||||
productID := itemCopy.ProductID
|
||||
needed := itemCopy.Quantity
|
||||
|
||||
// 1. 预检:SUM 是否充足
|
||||
var totalQty float64
|
||||
tx.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(&totalQty)
|
||||
if totalQty < needed {
|
||||
return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f",
|
||||
ErrInsufficientStock, item.ProductID, inv.Quantity, item.Quantity)
|
||||
ErrInsufficientStock, productID, totalQty, needed)
|
||||
}
|
||||
|
||||
qtyBefore := totalQty
|
||||
|
||||
// 2. FIFO 扣减批次
|
||||
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, warehouseID, productID).
|
||||
Order("created_at ASC").Find(&batches)
|
||||
|
||||
remaining := needed
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 写流水
|
||||
log := model.InventoryLog{
|
||||
ShopID: shopID,
|
||||
WarehouseID: warehouseID,
|
||||
ProductID: productID,
|
||||
Direction: "out",
|
||||
Quantity: needed,
|
||||
QtyBefore: qtyBefore,
|
||||
QtyAfter: qtyBefore - needed,
|
||||
RefType: "stock_out",
|
||||
RefID: orderID,
|
||||
OperatorID: &reviewerID,
|
||||
}
|
||||
if err := tx.Create(&log).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, item := range order.Items {
|
||||
if err := s.updateInventory(tx, shopID, order.WarehouseID, item.ProductID,
|
||||
"out", item.Quantity, orderID, "stock_out", reviewerID); err != nil {
|
||||
// 自动创建应收账款财务记录
|
||||
{
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
|
||||
oid := order.ID
|
||||
rec := model.FinanceRecord{
|
||||
ShopID: shopID,
|
||||
PartnerID: order.PartnerID,
|
||||
Type: "receivable",
|
||||
Amount: order.TotalAmount,
|
||||
Balance: bal,
|
||||
Status: "open",
|
||||
RefType: "stock_out",
|
||||
RefID: &oid,
|
||||
OperatorID: reviewerID,
|
||||
RecordDate: order.OrderDate.Time,
|
||||
}
|
||||
if err := tx.Create(&rec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -92,51 +245,17 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
})
|
||||
}
|
||||
|
||||
// updateInventory 更新库存并写流水(在事务中调用)
|
||||
func (s *StockService) updateInventory(tx *gorm.DB, shopID, warehouseID, productID uint64,
|
||||
direction string, qty float64, refID uint64, refType string, operatorID uint64) error {
|
||||
|
||||
var inv model.Inventory
|
||||
result := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND warehouse_id = ? AND product_id = ?",
|
||||
shopID, warehouseID, productID).First(&inv)
|
||||
|
||||
qtyBefore := inv.Quantity
|
||||
var qtyAfter float64
|
||||
|
||||
if direction == "in" {
|
||||
qtyAfter = qtyBefore + qty
|
||||
if result.Error != nil {
|
||||
// 不存在则创建
|
||||
inv = model.Inventory{ShopID: shopID, WarehouseID: warehouseID, ProductID: productID, Quantity: qtyAfter}
|
||||
if err := tx.Create(&inv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// partnerLastBalance 查询该往来单位最后一条财务记录的余额(用于计算滚动余额)
|
||||
func partnerLastBalance(tx *gorm.DB, shopID uint64, partnerID *uint64) float64 {
|
||||
var last model.FinanceRecord
|
||||
q := tx.Where("shop_id = ? AND deleted_at IS NULL", shopID)
|
||||
if partnerID != nil {
|
||||
q = q.Where("partner_id = ?", *partnerID)
|
||||
} else {
|
||||
qtyAfter = qtyBefore - qty
|
||||
if err := tx.Model(&inv).Update("quantity", qtyAfter).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
q = q.Where("partner_id IS NULL")
|
||||
}
|
||||
|
||||
log := model.InventoryLog{
|
||||
ShopID: shopID,
|
||||
WarehouseID: warehouseID,
|
||||
ProductID: productID,
|
||||
Direction: direction,
|
||||
Quantity: qty,
|
||||
QtyBefore: qtyBefore,
|
||||
QtyAfter: qtyAfter,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
OperatorID: &operatorID,
|
||||
}
|
||||
return tx.Create(&log).Error
|
||||
q.Order("id DESC").First(&last)
|
||||
return last.Balance
|
||||
}
|
||||
|
||||
// GenerateOrderNo 生成单号(事务安全,FOR UPDATE 防止并发重复单号)
|
||||
@@ -147,8 +266,16 @@ func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string,
|
||||
result := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule)
|
||||
if result.Error != nil {
|
||||
// 初始化规则
|
||||
rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: orderType[:2], CurrentNo: 0}
|
||||
// 初始化规则(使用中文惯用前缀)
|
||||
prefixMap := map[string]string{
|
||||
"stock_in": "RK", "stock_out": "CK",
|
||||
"inventory_check": "PD", "product": "SP",
|
||||
}
|
||||
prefix := prefixMap[orderType]
|
||||
if prefix == "" {
|
||||
prefix = strings.ToUpper(orderType[:2])
|
||||
}
|
||||
rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: prefix, DateFormat: "YYYYMMDD", CurrentNo: 0}
|
||||
tx.Create(&rule)
|
||||
}
|
||||
|
||||
|
||||
+33
-12
@@ -215,8 +215,9 @@ CREATE TABLE IF NOT EXISTS `stock_in_items` (
|
||||
`quantity` DECIMAL(12,3) NOT NULL COMMENT '数量',
|
||||
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '单价',
|
||||
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
||||
`batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号',
|
||||
`expire_date` DATE DEFAULT NULL COMMENT '有效期',
|
||||
`batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号',
|
||||
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
|
||||
`expire_date` DATE DEFAULT NULL COMMENT '有效期',
|
||||
`custom_fields` JSON DEFAULT NULL,
|
||||
`remark` VARCHAR(255) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
@@ -272,19 +273,38 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单明细';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 库存(实时)
|
||||
-- 库存(批次模式:每条记录代表一个批次/入库批)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `inventories` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`warehouse_id` BIGINT UNSIGNED NOT NULL,
|
||||
`product_id` BIGINT UNSIGNED NOT NULL,
|
||||
`quantity` DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT '当前库存',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`warehouse_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`product_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`stock_in_item_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`inventory_check_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`quantity` DECIMAL(12,3) NOT NULL DEFAULT 0,
|
||||
`product_code` VARCHAR(50) DEFAULT NULL,
|
||||
`product_name` VARCHAR(200) DEFAULT NULL,
|
||||
`series` VARCHAR(100) DEFAULT NULL,
|
||||
`spec` VARCHAR(100) DEFAULT NULL,
|
||||
`unit` VARCHAR(20) DEFAULT NULL,
|
||||
`warehouse_name` VARCHAR(100) DEFAULT NULL,
|
||||
`unit_price` DECIMAL(16,2) DEFAULT NULL,
|
||||
`production_date` DATE DEFAULT NULL,
|
||||
`batch_no` VARCHAR(50) DEFAULT NULL,
|
||||
`supplier_name` VARCHAR(200) DEFAULT NULL,
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_shop_wh_product` (`shop_id`, `warehouse_id`, `product_id`),
|
||||
KEY `idx_shop_id` (`shop_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实时库存';
|
||||
KEY `idx_shop_id` (`shop_id`),
|
||||
KEY `idx_fifo` (`shop_id`, `warehouse_id`, `product_id`, `created_at`),
|
||||
KEY `idx_shop_wh_product` (`shop_id`, `warehouse_id`, `product_id`),
|
||||
KEY `idx_stock_in_item` (`stock_in_item_id`),
|
||||
KEY `idx_inventory_check` (`inventory_check_id`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存批次记录';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 库存流水(出入库记录明细)
|
||||
@@ -349,6 +369,7 @@ CREATE TABLE IF NOT EXISTS `finance_records` (
|
||||
`type` ENUM('receivable','payable','receipt','payment') NOT NULL COMMENT '应收/应付/收款/付款',
|
||||
`amount` DECIMAL(16,2) NOT NULL,
|
||||
`balance` DECIMAL(16,2) NOT NULL COMMENT '操作后余额',
|
||||
`status` ENUM('open','closed') NOT NULL DEFAULT 'open' COMMENT '结清状态(payable/receivable 有效)',
|
||||
`ref_type` VARCHAR(30) DEFAULT NULL COMMENT '关联单据类型',
|
||||
`ref_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`operator_id` BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
@@ -35,7 +35,7 @@ SET FOREIGN_KEY_CHECKS = 1;
|
||||
-- ── 门店 ────────────────────────────────────────────────────
|
||||
-- id=1
|
||||
INSERT INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
|
||||
VALUES (1, '测试酒库门店', 'S001', '北京市朝阳区建国路88号', '010-12345678', '张总', NOW(), NOW());
|
||||
VALUES (1, '盛世名酿酒行', 'S001', '北京市朝阳区建国路88号华贸中心B座101室', '010-65882266', '张建国', NOW(), NOW());
|
||||
|
||||
-- ── 用户(密码均为 password123)────────────────────────────
|
||||
-- bcrypt(password123, cost=10)
|
||||
|
||||
@@ -30,7 +30,7 @@ SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- ── 门店 ────────────────────────────────────────────────────
|
||||
INSERT INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
|
||||
VALUES (1, '测试酒库门店', 'S002', '北京市朝阳区建国路88号', '010-12345678', '张总', NOW(), NOW());
|
||||
VALUES (1, '醇香汇酒业', 'S002', '上海市静安区南京西路1288号恒隆广场L1-06', '021-52088899', '李文博', NOW(), NOW());
|
||||
|
||||
-- ── 用户(密码均为 password123)────────────────────────────
|
||||
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- S003 种子数据(仅账号,不清空其他门店数据)
|
||||
-- 用法: sh scripts/dev.sh seed S003
|
||||
|
||||
-- 插入门店(若已存在则忽略)
|
||||
INSERT IGNORE INTO shops (name, code, address, phone, manager_name, created_at, updated_at)
|
||||
VALUES ('御品轩名酒坊', 'S003', '广州市天河区天河路385号太古汇ML-21', '020-38688866', '王志远', NOW(), NOW());
|
||||
|
||||
-- 获取 S003 的 shop_id
|
||||
SET @shop_id = (SELECT id FROM shops WHERE code = 'S003' LIMIT 1);
|
||||
|
||||
-- 密码均为 password123
|
||||
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
|
||||
|
||||
-- 插入用户(若已存在则更新密码)
|
||||
INSERT INTO users (shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at)
|
||||
VALUES
|
||||
(@shop_id, 'admin', @pwd, '管理员', '', 'admin', 1, NOW(), NOW()),
|
||||
(@shop_id, 'operator', @pwd, '操作员', '', 'operator', 1, NOW(), NOW()),
|
||||
(@shop_id, 'test', @pwd, '只读', '', 'readonly', 1, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE password_hash = @pwd, is_active = 1, updated_at = NOW();
|
||||
Reference in New Issue
Block a user