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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user