From c1ed81dfab969247d493e8046a0806bce0509382 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 23 May 2026 14:05:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A2=E5=8A=A1=E7=BB=93=E6=B8=85?= =?UTF-8?q?=E3=80=81=E9=85=92=E8=A1=8C=E4=BF=A1=E6=81=AF=E3=80=81=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E5=A4=87=E6=B3=A8=E7=BC=96=E8=BE=91=E3=80=81=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=BA=AF=E6=BA=90=E3=80=81=E5=85=A5=E5=BA=93=E5=BF=85?= =?UTF-8?q?=E5=A1=AB=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 - 新增 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 --- backend/cmd/seed/main.go | 60 +- backend/internal/handler/finance.go | 143 +++++ backend/internal/handler/import.go | 291 ++++++--- backend/internal/handler/inventory.go | 343 ++++++---- backend/internal/handler/number_rule.go | 20 + backend/internal/handler/product.go | 5 +- backend/internal/handler/product_option.go | 71 +++ backend/internal/handler/shop.go | 61 ++ backend/internal/handler/stock_out.go | 41 +- backend/internal/model/finance.go | 1 + backend/internal/model/stock.go | 39 +- backend/internal/router/router.go | 21 +- backend/internal/service/stock.go | 253 ++++++-- backend/schema/schema.sql | 45 +- backend/seeds/S001.sql | 2 +- backend/seeds/S002.sql | 2 +- backend/seeds/S003.sql | 20 + client/lib/core/api/api_client.dart | 3 + client/lib/core/config/app_config.dart | 23 +- client/lib/core/router/app_router.dart | 5 - client/lib/core/utils/dialog_util.dart | 21 + client/lib/core/utils/print_util.dart | 8 + client/lib/core/utils/print_util_stub.dart | 474 +++++++++++++- client/lib/core/utils/print_util_web.dart | 428 +++++++++---- client/lib/main.dart | 13 +- client/lib/models/finance.dart | 7 + client/lib/models/inventory.dart | 177 ++--- client/lib/models/shop.dart | 26 + client/lib/models/stock_in.dart | 7 + client/lib/models/stock_out.dart | 6 + .../providers/product_option_provider.dart | 15 + client/lib/providers/shop_provider.dart | 12 + client/lib/providers/tab_state_provider.dart | 6 + .../lib/repositories/finance_repository.dart | 34 + .../repositories/inventory_repository.dart | 21 +- .../product_option_repository.dart | 27 + client/lib/repositories/shop_repository.dart | 33 + client/lib/screens/auth/login_screen.dart | 35 +- .../lib/screens/finance/finance_screen.dart | 276 ++++++-- .../inventory/batch_tracking_screen.dart | 411 ------------ .../inventory/inventory_check_screen.dart | 9 +- .../inventory/inventory_list_screen.dart | 271 ++++++-- .../lib/screens/partners/partners_screen.dart | 3 +- .../products/product_detail_screen.dart | 8 +- .../lib/screens/products/products_screen.dart | 88 ++- .../lib/screens/settings/settings_screen.dart | 365 +++++++++-- client/lib/screens/shell/app_shell.dart | 12 +- .../stock_in/stock_in_form_screen.dart | 152 ++++- .../stock_in/stock_in_list_screen.dart | 299 +++++++-- .../stock_out/stock_out_form_screen.dart | 602 ++++++++++++------ .../stock_out/stock_out_list_screen.dart | 294 +++++++-- client/lib/widgets/form_dialog.dart | 1 + client/lib/widgets/multi_select_dropdown.dart | 7 +- client/lib/widgets/page_scaffold.dart | 83 ++- .../lib/widgets/searchable_option_field.dart | 1 + client/macos/Podfile.lock | 6 + client/pubspec.lock | 9 +- client/pubspec.yaml | 4 +- docs/context/project.md | 1 + docs/user-manual.md | 535 ++++++++++++++++ 60 files changed, 4664 insertions(+), 1572 deletions(-) create mode 100644 backend/internal/handler/shop.go create mode 100644 backend/seeds/S003.sql create mode 100644 client/lib/core/utils/dialog_util.dart create mode 100644 client/lib/models/shop.dart create mode 100644 client/lib/providers/shop_provider.dart create mode 100644 client/lib/providers/tab_state_provider.dart create mode 100644 client/lib/repositories/shop_repository.dart delete mode 100644 client/lib/screens/inventory/batch_tracking_screen.dart create mode 100644 docs/user-manual.md diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 1f6c5cd..3668cf8 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -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, }) } diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index 34292aa..5b09906 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -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 +} diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index c862db1..02a9e59 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -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) +} diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 267139c..b0ec94a 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -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}) +} diff --git a/backend/internal/handler/number_rule.go b/backend/internal/handler/number_rule.go index f077900..6db14be 100644 --- a/backend/internal/handler/number_rule.go +++ b/backend/internal/handler/number_rule.go @@ -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}) } diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index f131505..c62dea9 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -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()}) diff --git a/backend/internal/handler/product_option.go b/backend/internal/handler/product_option.go index aecc742..927a628 100644 --- a/backend/internal/handler/product_option.go +++ b/backend/internal/handler/product_option.go @@ -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{}) diff --git a/backend/internal/handler/shop.go b/backend/internal/handler/shop.go new file mode 100644 index 0000000..87e9acb --- /dev/null +++ b/backend/internal/handler/shop.go @@ -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) +} diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index f00d186..b5d0d57 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -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) } } diff --git a/backend/internal/model/finance.go b/backend/internal/model/finance.go index 3094d54..1831d58 100644 --- a/backend/internal/model/finance.go +++ b/backend/internal/model/finance.go @@ -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"` diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index 11f856c..af6a0cb 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -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"` } // -------- 库存流水 -------- diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6dd9c3a..3650a6a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index a42f50f..436f022 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -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) } diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index e7db156..405f0d8 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -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, diff --git a/backend/seeds/S001.sql b/backend/seeds/S001.sql index 98ed345..9ee0f1a 100644 --- a/backend/seeds/S001.sql +++ b/backend/seeds/S001.sql @@ -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) diff --git a/backend/seeds/S002.sql b/backend/seeds/S002.sql index 4533956..2bf46e3 100644 --- a/backend/seeds/S002.sql +++ b/backend/seeds/S002.sql @@ -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'; diff --git a/backend/seeds/S003.sql b/backend/seeds/S003.sql new file mode 100644 index 0000000..a11095e --- /dev/null +++ b/backend/seeds/S003.sql @@ -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(); diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart index 4c62036..de1528a 100644 --- a/client/lib/core/api/api_client.dart +++ b/client/lib/core/api/api_client.dart @@ -121,6 +121,9 @@ class ApiClient { Future put(String path, {dynamic data}) => _dio.put(path, data: data); + Future patch(String path, {dynamic data}) => + _dio.patch(path, data: data); + Future delete(String path) => _dio.delete(path); } diff --git a/client/lib/core/config/app_config.dart b/client/lib/core/config/app_config.dart index 616eb9c..65a858a 100644 --- a/client/lib/core/config/app_config.dart +++ b/client/lib/core/config/app_config.dart @@ -1,7 +1,11 @@ -/// 集中管理应用配置,通过 --dart-define=BASE_URL=... 注入环境变量 +/// 集中管理应用配置,通过 --dart-define=KEY=value 注入环境变量 /// /// 开发默认值:http://localhost:8080 -/// 生产部署示例:flutter run --dart-define=BASE_URL=http://192.168.1.100:8080 +/// 生产部署示例:flutter run \ +/// --dart-define=BASE_URL=http://192.168.1.100:8080 \ +/// --dart-define=SHOP_NAME=强朋友名酒行 \ +/// --dart-define=SHOP_ADDRESS=贵州省贵阳市云岩区中华北路88号 \ +/// --dart-define=SHOP_PHONE=0851-12345678 class AppConfig { const AppConfig._(); @@ -15,6 +19,21 @@ class AppConfig { defaultValue: 'http://localhost:8081', ); + static const shopName = String.fromEnvironment( + 'SHOP_NAME', + defaultValue: '酒库管理系统', + ); + + static const shopAddress = String.fromEnvironment( + 'SHOP_ADDRESS', + defaultValue: '', + ); + + static const shopPhone = String.fromEnvironment( + 'SHOP_PHONE', + defaultValue: '', + ); + static String get baseUrl => _baseUrl; static String get apiBaseUrl => '$_baseUrl/api/v1'; static String get healthUrl => '$_baseUrl/health'; diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 640ecde..ffaacce 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -9,7 +9,6 @@ import '../../screens/stock_out/stock_out_list_screen.dart'; import '../../screens/stock_out/stock_out_form_screen.dart'; import '../../screens/inventory/inventory_list_screen.dart'; import '../../screens/inventory/inventory_check_screen.dart'; -import '../../screens/inventory/batch_tracking_screen.dart'; import '../../screens/partners/partners_screen.dart'; import '../../screens/finance/finance_screen.dart'; import '../../screens/products/products_screen.dart'; @@ -115,10 +114,6 @@ final appRouterProvider = Provider((ref) { path: '/inventory/check', pageBuilder: (_, __) => _noTransition(const InventoryCheckScreen())), - GoRoute( - path: '/batches', - pageBuilder: (_, __) => - _noTransition(const BatchTrackingScreen())), GoRoute( path: '/partners', pageBuilder: (_, __) => _noTransition(const PartnersScreen())), diff --git a/client/lib/core/utils/dialog_util.dart b/client/lib/core/utils/dialog_util.dart new file mode 100644 index 0000000..037f199 --- /dev/null +++ b/client/lib/core/utils/dialog_util.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +/// showDialog 的统一封装,自动在内容外层加 SelectionArea, +/// 使对话框内的文字可以被选中/复制。 +Future showAppDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + Color? barrierColor, + bool useRootNavigator = true, + RouteSettings? routeSettings, +}) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + builder: (ctx) => SelectionArea(child: builder(ctx)), + ); +} diff --git a/client/lib/core/utils/print_util.dart b/client/lib/core/utils/print_util.dart index e741aa1..674cda0 100644 --- a/client/lib/core/utils/print_util.dart +++ b/client/lib/core/utils/print_util.dart @@ -12,6 +12,10 @@ Future printProductLabel({ String? series, String? batchNo, String? productionDate, + String? remark, + String shopName = '', + String shopAddress = '', + String shopPhone = '', }) => printProductLabelImpl( qrBytes: qrBytes, @@ -21,6 +25,10 @@ Future printProductLabel({ series: series, batchNo: batchNo, productionDate: productionDate, + remark: remark, + shopName: shopName, + shopAddress: shopAddress, + shopPhone: shopPhone, ); Future printStockInOrder(StockInOrder order) => diff --git a/client/lib/core/utils/print_util_stub.dart b/client/lib/core/utils/print_util_stub.dart index ee475aa..cf1428f 100644 --- a/client/lib/core/utils/print_util_stub.dart +++ b/client/lib/core/utils/print_util_stub.dart @@ -5,6 +5,41 @@ import 'package:printing/printing.dart'; import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; +Future _loadFont() async { + final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); + return pw.Font.ttf(data); +} + +Future _loadBoldFont() async { + // Fall back to regular if no bold variant + final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); + return pw.Font.ttf(data); +} + +// ── 设计色彩 token ────────────────────────────────────────────────────────── +const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip +const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text +const _muted = PdfColor(0.533, 0.533, 0.533); // #888 field labels +const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text +const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text + +pw.Widget _labelSpecRow(pw.Font font, String label, String value) => + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.SizedBox( + width: 30, + child: pw.Text(label, + style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)), + ), + pw.SizedBox(width: 4), + pw.Expanded( + child: pw.Text(value, + style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), + ), + ], + ); + Future printProductLabelImpl({ required Uint8List qrBytes, required String name, @@ -13,58 +48,431 @@ Future printProductLabelImpl({ String? series, String? batchNo, String? productionDate, + String? remark, + String shopName = '', + String shopAddress = '', + String shopPhone = '', }) async { - final fontData = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); - final font = pw.Font.ttf(fontData); + final font = await _loadFont(); final doc = pw.Document(); final qrImage = pw.MemoryImage(qrBytes); + + // Label: 4 × 2 inch + const labelW = 4.0 * PdfPageFormat.inch; + const labelH = 2.0 * PdfPageFormat.inch; + const headerH = labelH * 0.215; + const footerH = labelH * 0.09; + + final specVal = (spec ?? '').isNotEmpty ? spec! : '—'; + final seriesVal = (series ?? '').isNotEmpty ? series! : '—'; + final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '—'; + final dateVal = (productionDate ?? '').isNotEmpty + ? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate) + : '—'; + + final contact = [ + if (shopAddress.isNotEmpty) shopAddress, + if (shopPhone.isNotEmpty) shopPhone, + ].join(' · '); + final footerLeft = contact.isNotEmpty ? contact : shopName; + + // 标签生成时间 + final now = DateTime.now(); + final genTime = + '${now.year}-${now.month.toString().padLeft(2,'0')}-${now.day.toString().padLeft(2,'0')}' + ' ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}'; + doc.addPage(pw.Page( - pageFormat: const PdfPageFormat(4 * PdfPageFormat.inch, 2 * PdfPageFormat.inch), - margin: const pw.EdgeInsets.all(10), - build: (_) => pw.Row( + pageFormat: const PdfPageFormat(labelW, labelH), + margin: pw.EdgeInsets.zero, + build: (_) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ - pw.Expanded( - child: pw.Column( - mainAxisAlignment: pw.MainAxisAlignment.center, - crossAxisAlignment: pw.CrossAxisAlignment.start, + + // ── Header:酒行名称 ───────────────────────────────────────────────── + pw.Container( + height: headerH, + color: _navy, + padding: const pw.EdgeInsets.symmetric(horizontal: 11), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ - pw.Text(name, + pw.Flexible( + child: pw.Text(shopName, + style: pw.TextStyle( + font: font, fontSize: 13, + fontWeight: pw.FontWeight.bold, + color: _cream, letterSpacing: 1.5, + )), + ), + pw.Text('Certificate of Authenticity', style: pw.TextStyle( - font: font, fontSize: 14, fontWeight: pw.FontWeight.bold)), - pw.SizedBox(height: 3), - pw.Text('编号:$code', - style: pw.TextStyle(font: font, fontSize: 10)), - if ((series ?? '').isNotEmpty) - pw.Text('系列:$series', - style: pw.TextStyle(font: font, fontSize: 10)), - if ((spec ?? '').isNotEmpty) - pw.Text('规格:$spec', - style: pw.TextStyle(font: font, fontSize: 10)), - if ((batchNo ?? '').isNotEmpty) - pw.Text('批次:$batchNo', - style: pw.TextStyle(font: font, fontSize: 10)), - if ((productionDate ?? '').isNotEmpty) - pw.Text('生产日期:$productionDate', - style: pw.TextStyle(font: font, fontSize: 10)), + font: font, fontSize: 5, + color: const PdfColor(0.82, 0.79, 0.72))), ], ), ), - pw.SizedBox(width: 8), - pw.SizedBox( - width: 1.2 * PdfPageFormat.inch, - height: 1.2 * PdfPageFormat.inch, - child: pw.Image(qrImage), + + // ── Body ───────────────────────────────────────────────────────────── + pw.Expanded( + child: pw.Container( + color: PdfColors.white, + padding: const pw.EdgeInsets.fromLTRB(11, 8, 11, 6), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Expanded( + child: pw.Column( + mainAxisAlignment: pw.MainAxisAlignment.center, + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // 商品名 + pw.Text(name, + style: pw.TextStyle( + font: font, fontSize: 12, + fontWeight: pw.FontWeight.bold, + color: _ink, letterSpacing: 0.5)), + pw.SizedBox(height: 6), + // 规格 + 系列 + _label2ColRow(font, '规 格', specVal, '系 列', seriesVal), + pw.SizedBox(height: 3), + // 批号 + 生产日期 + _label2ColRow(font, '批 号', batchVal, '生产日期', dateVal), + if ((remark ?? '').isNotEmpty) ...[ + pw.SizedBox(height: 3), + _labelSpecRow(font, '备 注', remark!), + ], + ], + ), + ), + pw.SizedBox(width: 8), + // QR + pw.Column( + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + pw.SizedBox( + width: 60, height: 60, + child: pw.Image(qrImage, fit: pw.BoxFit.contain), + ), + pw.SizedBox(height: 3), + pw.Text('扫码溯源', + style: pw.TextStyle( + font: font, fontSize: 4.5, + color: PdfColors.grey600)), + ], + ), + ], + ), + ), + ), + + // ── Footer ─────────────────────────────────────────────────────────── + pw.Container( + height: footerH, + color: _cream, + padding: const pw.EdgeInsets.symmetric(horizontal: 11), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Flexible( + child: pw.Text(footerLeft, + style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)), + ), + pw.Text(genTime, + style: pw.TextStyle(font: font, fontSize: 4, color: _footnote)), + ], + ), ), ], ), )); + await Printing.layoutPdf(onLayout: (_) async => doc.save()); } +pw.Widget _label2ColRow( + pw.Font font, String lbl1, String val1, String lbl2, String val2) { + return pw.Row( + children: [ + pw.SizedBox( + width: 28, + child: pw.Text(lbl1, + style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)), + ), + pw.SizedBox(width: 3), + pw.Expanded( + child: pw.Text(val1, + style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), + ), + pw.SizedBox(width: 6), + pw.SizedBox( + width: 28, + child: pw.Text(lbl2, + style: pw.TextStyle(font: font, fontSize: 5.5, color: _muted)), + ), + pw.SizedBox(width: 3), + pw.Expanded( + child: pw.Text(val2, + style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)), + ), + ], + ); +} + +pw.Widget _buildOrderDoc({ + required pw.Font font, + required pw.Font bold, + required String title, + required String orderNo, + required String? orderDate, + required String? partnerLabel, + required String? partnerName, + required String? warehouseName, + required String? operatorName, + required String? reviewerName, + required String? remark, + required List headers, + required List> rows, + required double totalQty, + required double totalAmt, + String? tipsText, +}) { + final headerStyle = pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold); + final cellStyle = pw.TextStyle(font: font, fontSize: 9.5); + final metaStyle = pw.TextStyle(font: font, fontSize: 10); + final boldStyle = pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold); + + final colWidths = headers.map((h) { + if (h == '商品名称') return const pw.FlexColumnWidth(2.2); + if (h == '系列' || h == '规格') return const pw.FlexColumnWidth(1.2); + if (h == '商品编号') return const pw.FlexColumnWidth(1.2); + return const pw.FlexColumnWidth(0.9); + }).toList(); + + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Center( + child: pw.Text(title, + style: pw.TextStyle(font: bold, fontSize: 20, fontWeight: pw.FontWeight.bold)), + ), + pw.SizedBox(height: 8), + pw.Container( + decoration: const pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey600)), + ), + padding: const pw.EdgeInsets.only(bottom: 5), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text('$partnerLabel:${partnerName ?? ''}', style: metaStyle), + pw.Text('日期:${orderDate ?? ''}', style: metaStyle), + pw.Text('NO: $orderNo', style: boldStyle), + ], + ), + ), + pw.SizedBox(height: 4), + pw.Row( + children: [ + pw.Text('仓库:${warehouseName ?? ''}', style: metaStyle), + pw.SizedBox(width: 24), + pw.Text('经办人:${operatorName ?? ''}', style: metaStyle), + ], + ), + pw.SizedBox(height: 8), + pw.Table( + border: pw.TableBorder.all(color: PdfColors.grey500), + columnWidths: { for (var i = 0; i < colWidths.length; i++) i: colWidths[i] }, + children: [ + pw.TableRow( + decoration: const pw.BoxDecoration(color: PdfColors.grey200), + children: headers.map((h) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 3), + child: pw.Text(h, textAlign: pw.TextAlign.center, style: headerStyle), + )).toList(), + ), + ...rows.map((row) => pw.TableRow( + children: row.map((cell) => pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), + child: pw.Text(cell, style: cellStyle), + )).toList(), + )), + pw.TableRow( + decoration: const pw.BoxDecoration(color: PdfColors.grey100), + children: List.generate(headers.length, (i) { + if (i == headers.length - 3) { + return pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), + child: pw.Text('单据总计', textAlign: pw.TextAlign.right, style: boldStyle), + ); + } + if (i == headers.length - 2) { + return pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), + child: pw.Text( + totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3), + textAlign: pw.TextAlign.right, style: boldStyle, + ), + ); + } + if (i == headers.length - 1) { + return pw.Padding( + padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2), + child: pw.Text(totalAmt.toStringAsFixed(2), + textAlign: pw.TextAlign.right, style: boldStyle), + ); + } + return pw.SizedBox(); + }), + ), + ], + ), + if ((remark ?? '').isNotEmpty) ...[ + pw.SizedBox(height: 6), + pw.Text('备注:$remark', style: metaStyle), + ], + if (tipsText != null) ...[ + pw.SizedBox(height: 6), + pw.Container( + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey400), + color: PdfColors.grey50, + ), + padding: const pw.EdgeInsets.all(5), + child: pw.Text(tipsText, + style: pw.TextStyle(font: font, fontSize: 9, color: PdfColors.grey700)), + ), + ], + pw.SizedBox(height: 36), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + _sig(font, bold, '收/出货人(签字)'), + _sig(font, bold, '经办人:${operatorName ?? ''}'), + _sig(font, bold, '制单人:${reviewerName ?? operatorName ?? ''}'), + ], + ), + ], + ); +} + +pw.Widget _sig(pw.Font font, pw.Font bold, String label) { + return pw.Column( + children: [ + pw.Container(width: 120, height: 28), + pw.Container( + width: 120, + decoration: const pw.BoxDecoration( + border: pw.Border(top: pw.BorderSide(color: PdfColors.grey700)), + ), + padding: const pw.EdgeInsets.only(top: 3), + child: pw.Text(label, + textAlign: pw.TextAlign.center, + style: pw.TextStyle(font: font, fontSize: 10)), + ), + ], + ); +} + Future printStockInOrderImpl(StockInOrder order) async { - // Native PDF printing not implemented; no-op on non-web platforms. + final font = await _loadFont(); + final bold = await _loadBoldFont(); + + double totalQty = 0, totalAmt = 0; + final rows = >[]; + for (int i = 0; i < order.items.length; i++) { + final it = order.items[i]; + totalQty += it.quantity; + totalAmt += it.totalPrice; + rows.add([ + '${i + 1}', + it.productCode ?? '', + it.productName ?? '', + it.productSeries ?? '', + it.productSpec ?? '', + it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), + it.productUnit ?? '', + it.unitPrice.toStringAsFixed(2), + it.totalPrice.toStringAsFixed(2), + it.productionDate ?? '', + it.batchNo ?? '', + ]); + } + + final doc = pw.Document(); + doc.addPage(pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.symmetric(horizontal: 15 * PdfPageFormat.mm, vertical: 12 * PdfPageFormat.mm), + build: (_) => [ + _buildOrderDoc( + font: font, bold: bold, + title: '入 库 单', + orderNo: order.orderNo, + orderDate: order.orderDate, + partnerLabel: '供应商', + partnerName: order.partnerName, + warehouseName: order.warehouseName, + operatorName: order.operatorName, + reviewerName: order.reviewerName, + remark: order.remark, + headers: ['序号', '商品编号', '商品名称', '系列', '规格', '数量', '单位', '单价', '金额', '生产日期', '批次'], + rows: rows, + totalQty: totalQty, + totalAmt: totalAmt, + ), + ], + )); + await Printing.layoutPdf(onLayout: (_) async => doc.save()); } Future printStockOutOrderImpl(StockOutOrder order) async { - // Native PDF printing not implemented; no-op on non-web platforms. + final font = await _loadFont(); + final bold = await _loadBoldFont(); + + double totalQty = 0, totalAmt = 0; + final rows = >[]; + for (int i = 0; i < order.items.length; i++) { + final it = order.items[i]; + totalQty += it.quantity; + totalAmt += it.totalPrice; + rows.add([ + '${i + 1}', + it.productCode ?? '', + it.productName ?? '', + it.productSeries ?? '', + it.productSpec ?? '', + it.productUnit ?? '', + it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), + it.unitPrice.toStringAsFixed(2), + it.totalPrice.toStringAsFixed(2), + ]); + } + + final doc = pw.Document(); + doc.addPage(pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.symmetric(horizontal: 15 * PdfPageFormat.mm, vertical: 12 * PdfPageFormat.mm), + build: (_) => [ + _buildOrderDoc( + font: font, bold: bold, + title: '出 库 单', + orderNo: order.orderNo, + orderDate: order.orderDate, + partnerLabel: '客户', + partnerName: order.partnerName, + warehouseName: order.warehouseName, + operatorName: order.operatorName, + reviewerName: order.reviewerName, + remark: order.remark, + headers: ['序号', '商品编号', '商品名称', '系列', '规格', '单位', '数量', '单价', '金额'], + rows: rows, + totalQty: totalQty, + totalAmt: totalAmt, + tipsText: '温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。', + ), + ], + )); + await Printing.layoutPdf(onLayout: (_) async => doc.save()); } diff --git a/client/lib/core/utils/print_util_web.dart b/client/lib/core/utils/print_util_web.dart index 0d23824..2918be7 100644 --- a/client/lib/core/utils/print_util_web.dart +++ b/client/lib/core/utils/print_util_web.dart @@ -5,6 +5,14 @@ import 'package:web/web.dart' as web; import '../../models/stock_in.dart'; import '../../models/stock_out.dart'; +void _openPrintWindow(String html) { + final win = web.window.open('', '_blank'); + if (win != null) { + win.document.write(html.toJS); + win.document.close(); + } +} + Future printProductLabelImpl({ required Uint8List qrBytes, required String name, @@ -13,206 +21,380 @@ Future printProductLabelImpl({ String? series, String? batchNo, String? productionDate, + String? remark, + String shopName = '', + String shopAddress = '', + String shopPhone = '', }) async { final base64Img = base64Encode(qrBytes); - final leftRows = StringBuffer(); - leftRows.write('
$name
'); - leftRows.write('
编号:$code
'); - if (series != null && series.isNotEmpty) leftRows.write('
系列:$series
'); - if (spec != null && spec.isNotEmpty) leftRows.write('
规格:$spec
'); - if (batchNo != null && batchNo.isNotEmpty) leftRows.write('
批次:$batchNo
'); - if (productionDate != null && productionDate.isNotEmpty) { - leftRows.write('
生产日期:$productionDate
'); - } + final specVal = (spec ?? '').isNotEmpty ? spec! : '—'; + final seriesVal = (series ?? '').isNotEmpty ? series! : '—'; + final batchVal = (batchNo ?? '').isNotEmpty ? batchNo! : '—'; + final dateVal = (productionDate ?? '').isNotEmpty + ? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate) + : '—'; + + final footerContact = [ + if (shopAddress.isNotEmpty) shopAddress, + if (shopPhone.isNotEmpty) shopPhone, + ].join(' · '); + + // 标签生成时间 + final now = DateTime.now(); + final genTime = + '${now.year}-${now.month.toString().padLeft(2,'0')}-${now.day.toString().padLeft(2,'0')}' + ' ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}'; + + final remarkRow = (remark ?? '').isNotEmpty + ? '''
+ 备 注 + $remark +
''' + : ''; final html = ''' + + -
-
$leftRows
-
+
+ +
+ $shopName + Certificate of Authenticity
- + +
+
+
$name
+ +
+ 规 格 + $specVal + 系 列 + $seriesVal +
+ +
+ 批 号 + $batchVal + 生产日期 + $dateVal +
+ + $remarkRow +
+ +
+ +
扫码溯源 · TRACE
+
+
+ +
+ ${footerContact.isNotEmpty ? footerContact : shopName} + $genTime +
+ +
+ '''; - final win = web.window.open('', '_blank'); - if (win != null) { - win.document.write(html.toJS); - win.document.close(); - } + _openPrintWindow(html); } +// ── 入库单 ────────────────────────────────────────────────────────────────── + Future printStockInOrderImpl(StockInOrder order) async { final rows = StringBuffer(); - double total = 0; + double totalQty = 0; + double totalAmt = 0; for (int i = 0; i < order.items.length; i++) { final item = order.items[i]; - total += item.totalPrice; + totalQty += item.quantity; + totalAmt += item.totalPrice; rows.write(''' - ${i + 1} + ${i + 1} + ${item.productCode ?? ''} ${item.productName ?? ''} - ${item.productSeries ?? ''} - ${item.productSpec ?? ''} - ${item.batchNo ?? ''} - ${item.quantity.toStringAsFixed(0)} - ${item.productUnit ?? ''} - ${item.unitPrice.toStringAsFixed(2)} - ${item.totalPrice.toStringAsFixed(2)} + ${item.productSeries ?? ''} + ${item.productSpec ?? ''} + ${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)} + ${item.productUnit ?? ''} + ${item.unitPrice.toStringAsFixed(2)} + ${item.totalPrice.toStringAsFixed(2)} + ${item.productionDate ?? ''} + ${item.batchNo ?? ''} '''); } + final remarkLine = (order.remark ?? '').isNotEmpty + ? '
备注:${order.remark}
' + : ''; + final html = ''' -

入库单

-
-
单号:${order.orderNo}
-
日期:${order.orderDate ?? ''}
-
供应商:${order.partnerName ?? ''}
-
仓库:${order.warehouseName ?? ''}
-
采购人:${order.operatorName ?? ''}
-
备注:${order.remark ?? ''}
+
入 库 单
+
+
供应商:${order.partnerName ?? ''}
+
入库日期:${order.orderDate ?? ''}
+
NO: ${order.orderNo}
+
+
+ 仓库:${order.warehouseName ?? ''} + 采购人:${order.operatorName ?? ''}
- - + + + + + + + + + + + $rows - - - + + + + + +
序号商品名称系列规格批次数量单位单价金额序号商品编号商品名称系列规格数量单位单价金额生产日期批次号
合计${total.toStringAsFixed(2)}
单据总计${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}${totalAmt.toStringAsFixed(2)}
+ $remarkLine
-
制单人
-
审核人
-
供应商签字
+
出货人(签字)
+
采购员:${order.operatorName ?? ''}
+
制单人:${order.reviewerName ?? order.operatorName ?? ''}
- + + '''; - final win = web.window.open('', '_blank'); - if (win != null) { - win.document.write(html.toJS); - win.document.close(); - } + _openPrintWindow(html); } +// ── 出库单 ────────────────────────────────────────────────────────────────── + Future printStockOutOrderImpl(StockOutOrder order) async { final rows = StringBuffer(); - double total = 0; + double totalQty = 0; + double totalAmt = 0; for (int i = 0; i < order.items.length; i++) { final item = order.items[i]; - total += item.totalPrice; + totalQty += item.quantity; + totalAmt += item.totalPrice; rows.write(''' - ${i + 1} + ${i + 1} + ${item.productCode ?? ''} ${item.productName ?? ''} - ${item.productSeries ?? ''} - ${item.productSpec ?? ''} - ${item.quantity.toStringAsFixed(0)} - ${item.productUnit ?? ''} - ${item.unitPrice.toStringAsFixed(2)} - ${item.totalPrice.toStringAsFixed(2)} + ${item.productSeries ?? ''} + ${item.productSpec ?? ''} + ${item.productUnit ?? ''} + ${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)} + ${item.unitPrice.toStringAsFixed(2)} + ${item.totalPrice.toStringAsFixed(2)} '''); } + final remarkLine = (order.remark ?? '').isNotEmpty + ? '
备注:${order.remark}
' + : ''; + final html = ''' -
-

温馨提示

-
出库通知单
+
出 库 单
+
+
客户:${order.partnerName ?? ''}
+
出库日期:${order.orderDate ?? ''}
+
NO.${order.orderNo}
-
-
单号:${order.orderNo}
-
日期:${order.orderDate ?? ''}
-
客户:${order.partnerName ?? ''}
-
仓库:${order.warehouseName ?? ''}
-
经办人:${order.operatorName ?? ''}
-
备注:${order.remark ?? ''}
+
+ 仓库:${order.warehouseName ?? ''} + 经办人:${order.operatorName ?? ''}
- - + + + + + + + + + $rows - - - + + + + +
序号商品名称系列规格数量单位单价金额序号商品编号商品名称系列规格单位数量单价金额
合计${total.toStringAsFixed(2)}
单据总计${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}${totalAmt.toStringAsFixed(2)}
+ $remarkLine +
温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。
-
制单人
-
审核人
-
客户签字
+
收货人(签字)
+
销售员:${order.operatorName ?? ''}
+
制单人:${order.reviewerName ?? order.operatorName ?? ''}
- + + '''; - final win = web.window.open('', '_blank'); - if (win != null) { - win.document.write(html.toJS); - win.document.close(); - } + _openPrintWindow(html); } diff --git a/client/lib/main.dart b/client/lib/main.dart index 94855e0..2d0fec2 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'core/auth/auth_state.dart'; @@ -54,8 +55,16 @@ class _JiuAppState extends ConsumerState { theme: AppTheme.light(), routerConfig: router, debugShowCheckedModeBanner: false, - builder: (context, child) => - SelectionArea(child: child ?? const SizedBox()), + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('zh', 'CN'), + Locale('en', 'US'), + ], + locale: const Locale('zh', 'CN'), ); } } diff --git a/client/lib/models/finance.dart b/client/lib/models/finance.dart index 2093a22..631b5e7 100644 --- a/client/lib/models/finance.dart +++ b/client/lib/models/finance.dart @@ -3,9 +3,11 @@ class FinanceRecord { final String type; // receivable | payable | receipt | payment final double amount; final double balance; + final String status; // open | closed final String? refType; final int? refId; final String? partnerName; + final int? partnerId; final String? recordDate; final String? remark; @@ -14,9 +16,11 @@ class FinanceRecord { required this.type, required this.amount, required this.balance, + this.status = 'open', this.refType, this.refId, this.partnerName, + this.partnerId, this.recordDate, this.remark, }); @@ -41,11 +45,14 @@ class FinanceRecord { type: json['type'] as String, amount: (json['amount'] as num).toDouble(), balance: (json['balance'] as num).toDouble(), + status: json['status'] as String? ?? 'open', refType: json['ref_type'] as String?, refId: json['ref_id'] != null ? (json['ref_id'] as num).toInt() : null, partnerName: (json['partner'] as Map?)?['name'] as String?, + partnerId: + json['partner_id'] != null ? (json['partner_id'] as num).toInt() : null, recordDate: json['record_date'] as String?, remark: json['remark'] as String?, ); diff --git a/client/lib/models/inventory.dart b/client/lib/models/inventory.dart index bee39e3..afa6f38 100644 --- a/client/lib/models/inventory.dart +++ b/client/lib/models/inventory.dart @@ -1,118 +1,72 @@ -class Inventory { - final int warehouseId; - final String? warehouseName; - final int productId; - final String? productName; - final String? productCode; - final String? productSpec; - final String? productUnit; - final String? productBrand; - final int? minStock; - final double quantity; - - const Inventory({ - required this.warehouseId, - this.warehouseName, - required this.productId, - this.productName, - this.productCode, - this.productSpec, - this.productUnit, - this.productBrand, - this.minStock, - required this.quantity, - }); - - factory Inventory.fromJson(Map json) { - final warehouse = json['warehouse'] as Map?; - final product = json['product'] as Map?; - return Inventory( - warehouseId: (json['warehouse_id'] as num).toInt(), - warehouseName: warehouse?['name'] as String?, - productId: (json['product_id'] as num).toInt(), - productName: product?['name'] as String?, - productCode: product?['code'] as String?, - productSpec: product?['spec'] as String?, - productUnit: product?['unit'] as String?, - productBrand: product?['brand'] as String?, - minStock: product?['min_stock'] != null - ? (product!['min_stock'] as num).toInt() - : null, - quantity: (json['quantity'] as num).toDouble(), - ); - } +String? _parseDate(String? s) { + if (s == null || s.isEmpty) return null; + return s.length >= 10 ? s.substring(0, 10) : s; } -// 商品追踪:已审核入库单的明细行,含库存状态和买家信息 -class ProductTrackingRecord { +class Inventory { final int id; - final int productId; - final String? productName; - final String? productCode; - final String? productSpec; - final String? productUnit; - final String? batchNo; + final int? warehouseId; + final int? productId; + final int? stockInItemId; final double quantity; - final double unitPrice; - final String? orderNo; - final String? orderDate; - final String? warehouseName; - final String? supplierName; - // 库存状态 - final double currentQty; - final String status; // in_stock | sold_out - final String? buyerName; - final String? soldAt; + final String productCode; + final String productName; + final String series; + final String spec; + final String unit; + final String warehouseName; + final double? unitPrice; + final String? productionDate; + final String batchNo; + final String supplierName; + final String remark; + final int? minStock; + final String brand; + final String? createdAt; - const ProductTrackingRecord({ + const Inventory({ required this.id, - required this.productId, - this.productName, - this.productCode, - this.productSpec, - this.productUnit, - this.batchNo, + this.warehouseId, + this.productId, + this.stockInItemId, required this.quantity, - required this.unitPrice, - this.orderNo, - this.orderDate, - this.warehouseName, - this.supplierName, - required this.currentQty, - required this.status, - this.buyerName, - this.soldAt, + this.productCode = '', + this.productName = '', + this.series = '', + this.spec = '', + this.unit = '', + this.warehouseName = '', + this.unitPrice, + this.productionDate, + this.batchNo = '', + this.supplierName = '', + this.remark = '', + this.minStock, + this.brand = '', + this.createdAt, }); - bool get isSoldOut => status == 'sold_out'; - - factory ProductTrackingRecord.fromJson(Map json) { - final product = json['product'] as Map?; - final order = json['order'] as Map?; - final warehouse = order?['warehouse'] as Map?; - final partner = order?['partner'] as Map?; - return ProductTrackingRecord( - id: (json['id'] as num).toInt(), - productId: (json['product_id'] as num).toInt(), - productName: product?['name'] as String?, - productCode: product?['code'] as String?, - productSpec: product?['spec'] as String?, - productUnit: product?['unit'] as String?, - batchNo: json['batch_no'] as String?, - quantity: (json['quantity'] as num).toDouble(), - unitPrice: (json['unit_price'] as num).toDouble(), - orderNo: order?['order_no'] as String?, - orderDate: order?['order_date'] as String?, - warehouseName: warehouse?['name'] as String?, - supplierName: partner?['name'] as String?, - currentQty: json['current_qty'] != null - ? (json['current_qty'] as num).toDouble() - : 0, - status: json['status'] as String? ?? 'in_stock', - buyerName: json['buyer_name'] as String?, - soldAt: json['sold_at'] as String?, - ); - } + factory Inventory.fromJson(Map json) => Inventory( + id: (json['id'] as num).toInt(), + warehouseId: (json['warehouse_id'] as num?)?.toInt(), + productId: (json['product_id'] as num?)?.toInt(), + stockInItemId: (json['stock_in_item_id'] as num?)?.toInt(), + quantity: (json['quantity'] as num).toDouble(), + productCode: json['product_code'] as String? ?? '', + productName: json['product_name'] as String? ?? '', + series: json['series'] as String? ?? '', + spec: json['spec'] as String? ?? '', + unit: json['unit'] as String? ?? '', + warehouseName: json['warehouse_name'] as String? ?? '', + unitPrice: (json['unit_price'] as num?)?.toDouble(), + productionDate: _parseDate(json['production_date'] as String?), + batchNo: json['batch_no'] as String? ?? '', + supplierName: json['supplier_name'] as String? ?? '', + remark: json['remark'] as String? ?? '', + minStock: (json['min_stock'] as num?)?.toInt(), + brand: json['brand'] as String? ?? '', + createdAt: json['created_at'] as String?, + ); } class InventoryLog { @@ -144,9 +98,11 @@ class InventoryLog { factory InventoryLog.fromJson(Map json) => InventoryLog( warehouseId: (json['warehouse_id'] as num).toInt(), - warehouseName: (json['warehouse'] as Map?)?['name'] as String?, + warehouseName: + (json['warehouse'] as Map?)?['name'] as String?, productId: (json['product_id'] as num).toInt(), - productName: (json['product'] as Map?)?['name'] as String?, + productName: + (json['product'] as Map?)?['name'] as String?, direction: json['direction'] as String, quantity: (json['quantity'] as num).toDouble(), qtyBefore: json['qty_before'] != null @@ -156,9 +112,8 @@ class InventoryLog { ? (json['qty_after'] as num).toDouble() : null, refType: json['ref_type'] as String?, - refId: json['ref_id'] != null - ? (json['ref_id'] as num).toInt() - : null, + refId: + json['ref_id'] != null ? (json['ref_id'] as num).toInt() : null, createdAt: json['created_at'] as String?, ); } diff --git a/client/lib/models/shop.dart b/client/lib/models/shop.dart new file mode 100644 index 0000000..cad8963 --- /dev/null +++ b/client/lib/models/shop.dart @@ -0,0 +1,26 @@ +class ShopInfo { + final int id; + final String code; + final String name; + final String address; + final String phone; + final String managerName; + + const ShopInfo({ + required this.id, + required this.code, + required this.name, + required this.address, + required this.phone, + required this.managerName, + }); + + factory ShopInfo.fromJson(Map json) => ShopInfo( + id: (json['id'] as num).toInt(), + code: json['code'] as String? ?? '', + name: json['name'] as String? ?? '', + address: json['address'] as String? ?? '', + phone: json['phone'] as String? ?? '', + managerName: json['manager_name'] as String? ?? '', + ); +} diff --git a/client/lib/models/stock_in.dart b/client/lib/models/stock_in.dart index 6092a09..892e0cd 100644 --- a/client/lib/models/stock_in.dart +++ b/client/lib/models/stock_in.dart @@ -5,6 +5,7 @@ class StockInItem { final double unitPrice; final double totalPrice; final String? batchNo; + final String? productionDate; // Denormalized for display final String? productName; final String? productCode; @@ -19,6 +20,7 @@ class StockInItem { required this.unitPrice, required this.totalPrice, this.batchNo, + this.productionDate, this.productName, this.productCode, this.productSeries, @@ -35,6 +37,7 @@ class StockInItem { unitPrice: (json['unit_price'] as num).toDouble(), totalPrice: (json['total_price'] as num).toDouble(), batchNo: json['batch_no'] as String?, + productionDate: json['production_date'] as String?, productName: (json['product'] as Map?)?['name'] as String?, productCode: (json['product'] as Map?)?['code'] as String?, productSeries: (json['product'] as Map?)?['series'] as String?, @@ -48,6 +51,7 @@ class StockInItem { 'unit_price': unitPrice, 'total_price': totalPrice, if (batchNo != null) 'batch_no': batchNo, + if (productionDate != null) 'production_date': productionDate, }; } @@ -65,6 +69,7 @@ class StockInOrder { final String? reviewerName; final String status; // draft | pending | approved | rejected final String? orderDate; + final String? reviewedAt; final double? totalAmount; final String? remark; final List items; @@ -83,6 +88,7 @@ class StockInOrder { this.reviewerName, required this.status, this.orderDate, + this.reviewedAt, this.totalAmount, this.remark, this.items = const [], @@ -108,6 +114,7 @@ class StockInOrder { reviewerName: (json['reviewer'] as Map?)?['real_name'] as String?, status: json['status'] as String, orderDate: json['order_date'] as String?, + reviewedAt: json['reviewed_at'] as String?, totalAmount: json['total_amount'] != null ? (json['total_amount'] as num).toDouble() : null, diff --git a/client/lib/models/stock_out.dart b/client/lib/models/stock_out.dart index d5daf18..415a10a 100644 --- a/client/lib/models/stock_out.dart +++ b/client/lib/models/stock_out.dart @@ -53,6 +53,8 @@ class StockOutOrder { final String? reviewerName; final String status; // draft | pending | approved | rejected final String? orderDate; + final String? reviewedAt; + final String? createdAt; final double? totalAmount; final String? remark; final List items; @@ -71,6 +73,8 @@ class StockOutOrder { this.reviewerName, required this.status, this.orderDate, + this.reviewedAt, + this.createdAt, this.totalAmount, this.remark, this.items = const [], @@ -96,6 +100,8 @@ class StockOutOrder { reviewerName: (json['reviewer'] as Map?)?['real_name'] as String?, status: json['status'] as String, orderDate: json['order_date'] as String?, + reviewedAt: json['reviewed_at'] as String?, + createdAt: json['created_at'] as String?, totalAmount: json['total_amount'] != null ? (json['total_amount'] as num).toDouble() : null, diff --git a/client/lib/providers/product_option_provider.dart b/client/lib/providers/product_option_provider.dart index 69cda13..323c14f 100644 --- a/client/lib/providers/product_option_provider.dart +++ b/client/lib/providers/product_option_provider.dart @@ -37,6 +37,11 @@ class ProductNameListNotifier extends AsyncNotifier> { reload(); } + Future updateItem(int id, Map data) async { + await ref.read(productOptionRepositoryProvider).updateName(id, data); + reload(); + } + Future delete(int id) async { await ref.read(productOptionRepositoryProvider).deleteName(id); reload(); @@ -71,6 +76,11 @@ class ProductSeriesListNotifier extends AsyncNotifier> reload(); } + Future updateItem(int id, Map data) async { + await ref.read(productOptionRepositoryProvider).updateSeries(id, data); + reload(); + } + Future delete(int id) async { await ref.read(productOptionRepositoryProvider).deleteSeries(id); reload(); @@ -105,6 +115,11 @@ class ProductSpecListNotifier extends AsyncNotifier> { reload(); } + Future updateItem(int id, Map data) async { + await ref.read(productOptionRepositoryProvider).updateSpec(id, data); + reload(); + } + Future delete(int id) async { await ref.read(productOptionRepositoryProvider).deleteSpec(id); reload(); diff --git a/client/lib/providers/shop_provider.dart b/client/lib/providers/shop_provider.dart new file mode 100644 index 0000000..1f81516 --- /dev/null +++ b/client/lib/providers/shop_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../models/shop.dart'; +import '../repositories/shop_repository.dart'; + +final shopRepositoryProvider = Provider((ref) { + return ShopRepository(ref.watch(apiClientProvider)); +}); + +final shopInfoProvider = FutureProvider((ref) { + return ref.watch(shopRepositoryProvider).getInfo(); +}); diff --git a/client/lib/providers/tab_state_provider.dart b/client/lib/providers/tab_state_provider.dart new file mode 100644 index 0000000..48251ee --- /dev/null +++ b/client/lib/providers/tab_state_provider.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final stockInTabProvider = StateProvider((ref) => 0); +final stockOutTabProvider = StateProvider((ref) => 0); +final inventoryTabProvider = StateProvider((ref) => 0); +final financeTabProvider = StateProvider((ref) => 0); diff --git a/client/lib/repositories/finance_repository.dart b/client/lib/repositories/finance_repository.dart index 2a425e9..39b02f1 100644 --- a/client/lib/repositories/finance_repository.dart +++ b/client/lib/repositories/finance_repository.dart @@ -33,4 +33,38 @@ class FinanceRepository { ); } } + + Future create(Map body) async { + try { + await _client.post('/finance/records', data: body); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '创建记录失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future close(int id) async { + try { + await _client.put('/finance/records/$id/close'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '操作失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future closeByRef(String refType, int refId) async { + try { + await _client.put( + '/finance/records/close-by-ref?ref_type=$refType&ref_id=$refId'); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '结清失败', + statusCode: e.response?.statusCode, + ); + } + } } diff --git a/client/lib/repositories/inventory_repository.dart b/client/lib/repositories/inventory_repository.dart index 3421c5b..4cb1e57 100644 --- a/client/lib/repositories/inventory_repository.dart +++ b/client/lib/repositories/inventory_repository.dart @@ -47,27 +47,12 @@ class InventoryRepository { } } - Future> listProducts({ - int? productId, - int? warehouseId, - int page = 1, - int pageSize = 20, - }) async { + Future updateRemark(int id, String remark) async { try { - final params = { - 'page': page, - 'page_size': pageSize, - if (productId != null) 'product_id': productId, - if (warehouseId != null) 'warehouse_id': warehouseId, - }; - final resp = await _client.get('/inventory/products', params: params); - return PageResult.fromJson( - resp.data as Map, - ProductTrackingRecord.fromJson, - ); + await _client.put('/inventory/$id/remark', data: {'remark': remark}); } on DioException catch (e) { throw AppException( - e.response?.data?['error'] as String? ?? '获取商品追踪数据失败', + e.response?.data?['error'] as String? ?? '更新备注失败', statusCode: e.response?.statusCode, ); } diff --git a/client/lib/repositories/product_option_repository.dart b/client/lib/repositories/product_option_repository.dart index 5806583..1c4f177 100644 --- a/client/lib/repositories/product_option_repository.dart +++ b/client/lib/repositories/product_option_repository.dart @@ -30,6 +30,15 @@ class ProductOptionRepository { } } + Future updateName(int id, Map data) async { + try { + await _client.put('/product-options/names/$id', data: data); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '更新失败', + statusCode: e.response?.statusCode); + } + } + Future deleteName(int id) async { try { await _client.delete('/product-options/names/$id'); @@ -61,6 +70,15 @@ class ProductOptionRepository { } } + Future updateSeries(int id, Map data) async { + try { + await _client.put('/product-options/series/$id', data: data); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '更新失败', + statusCode: e.response?.statusCode); + } + } + Future deleteSeries(int id) async { try { await _client.delete('/product-options/series/$id'); @@ -92,6 +110,15 @@ class ProductOptionRepository { } } + Future updateSpec(int id, Map data) async { + try { + await _client.put('/product-options/specs/$id', data: data); + } on DioException catch (e) { + throw AppException(e.response?.data?['error'] as String? ?? '更新失败', + statusCode: e.response?.statusCode); + } + } + Future deleteSpec(int id) async { try { await _client.delete('/product-options/specs/$id'); diff --git a/client/lib/repositories/shop_repository.dart b/client/lib/repositories/shop_repository.dart new file mode 100644 index 0000000..26e6982 --- /dev/null +++ b/client/lib/repositories/shop_repository.dart @@ -0,0 +1,33 @@ +import 'package:dio/dio.dart'; +import '../core/api/api_client.dart'; +import '../core/exceptions.dart'; +import '../models/shop.dart'; + +class ShopRepository { + final ApiClient _client; + const ShopRepository(this._client); + + Future getInfo() async { + try { + final resp = await _client.get('/shop/info'); + return ShopInfo.fromJson(resp.data as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取酒行信息失败', + statusCode: e.response?.statusCode, + ); + } + } + + Future updateInfo(Map body) async { + try { + final resp = await _client.put('/shop/info', data: body); + return ShopInfo.fromJson(resp.data as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '更新失败', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart index bfa4888..9bde887 100644 --- a/client/lib/screens/auth/login_screen.dart +++ b/client/lib/screens/auth/login_screen.dart @@ -46,20 +46,32 @@ class _LoginScreenState extends ConsumerState { super.initState(); _loadHistory(); _hotelCodeFocus.addListener(() { - if (_hotelCodeFocus.hasFocus && _hotelCodeHistory.isNotEmpty) { - _openDropdown(_hotelLayerLink, _hotelCodeHistory, _shopCodeCtrl, - isHotel: true); - } else if (!_hotelCodeFocus.hasFocus) { + if (_hotelCodeFocus.hasFocus) { + _closeHotel(); + _loadHistory().then((_) { + if (!mounted || !_hotelCodeFocus.hasFocus) return; + if (_hotelCodeHistory.isNotEmpty) { + _openDropdown(_hotelLayerLink, _hotelCodeHistory, _shopCodeCtrl, + isHotel: true); + } + }); + } else { // 延迟关闭:让候选词的 onTap(pointer-up)先执行,再关闭下拉框 _closeHotelTimer?.cancel(); _closeHotelTimer = Timer(const Duration(milliseconds: 150), _closeHotel); } }); _usernameFocus.addListener(() { - if (_usernameFocus.hasFocus && _usernameHistory.isNotEmpty) { - _openDropdown(_usernameLayerLink, _usernameHistory, _usernameCtrl, - isHotel: false); - } else if (!_usernameFocus.hasFocus) { + if (_usernameFocus.hasFocus) { + _closeUsername(); + _loadHistory().then((_) { + if (!mounted || !_usernameFocus.hasFocus) return; + if (_usernameHistory.isNotEmpty) { + _openDropdown(_usernameLayerLink, _usernameHistory, _usernameCtrl, + isHotel: false); + } + }); + } else { _closeUsernameTimer?.cancel(); _closeUsernameTimer = Timer(const Duration(milliseconds: 150), _closeUsername); } @@ -74,13 +86,6 @@ class _LoginScreenState extends ConsumerState { _hotelCodeHistory = hotels; _usernameHistory = users; }); - // If a field is already focused, open its dropdown now that history loaded - if (_hotelCodeFocus.hasFocus && hotels.isNotEmpty) { - _openDropdown(_hotelLayerLink, hotels, _shopCodeCtrl, isHotel: true); - } - if (_usernameFocus.hasFocus && users.isNotEmpty) { - _openDropdown(_usernameLayerLink, users, _usernameCtrl, isHotel: false); - } } void _openDropdown( diff --git a/client/lib/screens/finance/finance_screen.dart b/client/lib/screens/finance/finance_screen.dart index 6b58fa5..20d97a1 100644 --- a/client/lib/screens/finance/finance_screen.dart +++ b/client/lib/screens/finance/finance_screen.dart @@ -8,6 +8,7 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto import '../../widgets/page_scaffold.dart'; import '../../providers/connectivity_provider.dart'; import '../../core/utils/export_util.dart'; +import '../../repositories/finance_repository.dart'; class FinanceScreen extends ConsumerWidget { const FinanceScreen({super.key}); @@ -30,7 +31,6 @@ class FinanceScreen extends ConsumerWidget { } } -// Each tab has its own independent state — avoids shared-provider conflicts class _FinanceTab extends ConsumerStatefulWidget { final String typeFilter; const _FinanceTab({required this.typeFilter}); @@ -44,7 +44,6 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { int _page = 1; int _pageSize = 20; - // We drive fetches by maintaining a Future locally, bypassing the global provider late Future> _future; List _allRecords = []; int _total = 0; @@ -60,7 +59,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ColDef('ref', '关联单据', minWidth: 900), ColDef('amount', '金额'), ColDef('balance', '余额'), + ColDef('status', '状态'), ColDef('remark', '备注', minWidth: 1000), + ColDef('actions', '操作'), ]; @override @@ -104,9 +105,32 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { }).toList(); } + Future _closeRecord(FinanceRecord r) async { + try { + await ref.read(financeRepositoryProvider).close(r.id); + _refetch(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } + } + + void _showAddDialog() { + showDialog( + context: context, + builder: (_) => _AddPaymentDialog( + typeFilter: widget.typeFilter, + onSaved: _refetch, + repo: ref.read(financeRepositoryProvider), + ), + ); + } + @override Widget build(BuildContext context) { - // 网络恢复时自动刷新 ref.listen(networkRecoveryCountProvider, (_, __) => _refetch()); return FutureBuilder>( @@ -144,16 +168,16 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { } Widget _buildContent(List records) { + // Summary: only for payable/receivable tabs final totalAmount = records.fold(0.0, (s, r) => s + r.amount); - final totalBalance = records.fold(0.0, (s, r) => s + r.balance); - final totalPaid = totalAmount - totalBalance; + final openAmount = records + .where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'open') + .fold(0.0, (s, r) => s + r.amount); + final closedAmount = records + .where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'closed') + .fold(0.0, (s, r) => s + r.amount); - // Derive filter options from all loaded records - final typeOptions = _allRecords - .map((r) => r.typeLabel) - .toSet() - .toList() - ..sort(); + final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()..sort(); final partnerOptions = _allRecords .map((r) => r.partnerName ?? '') .where((s) => s.isNotEmpty) @@ -161,7 +185,6 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { .toList() ..sort(); - // Build visible columns (manual hide + responsive auto-hide by screen width) final screenWidth = MediaQuery.of(context).size.width; final visibleCols = _colDefs .where((c) => @@ -202,8 +225,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { case 'partner': return DataCell(SizedBox( width: 160, - child: Text(r.partnerName ?? '-', - overflow: TextOverflow.ellipsis), + child: Text(r.partnerName ?? '-', overflow: TextOverflow.ellipsis), )); case 'ref': return DataCell(Text( @@ -211,9 +233,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' : '-', style: const TextStyle( - fontSize: 11, - fontFamily: 'monospace', - color: AppTheme.primary), + fontSize: 11, fontFamily: 'monospace', color: AppTheme.primary), )); case 'amount': return DataCell(Text( @@ -224,19 +244,31 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { return DataCell(Text( '¥${r.balance.toStringAsFixed(2)}', style: TextStyle( - color: - r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary, + color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary, fontWeight: FontWeight.w600, ), )); + case 'status': + if (r.type != 'payable' && r.type != 'receivable') { + return const DataCell(SizedBox()); + } + return DataCell(_StatusBadge(r.status)); case 'remark': return DataCell(SizedBox( width: 160, child: Text(r.remark ?? '-', overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), )); + case 'actions': + if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open') { + return DataCell(TextButton( + onPressed: () => _closeRecord(r), + child: const Text('结清', + style: TextStyle(fontSize: 12, color: AppTheme.success)), + )); + } + return const DataCell(SizedBox()); default: return const DataCell(SizedBox()); } @@ -256,15 +288,19 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { ] : records .map((r) => DataRow( - cells: visibleCols - .map((c) => buildFinanceCell(c.key, r)) - .toList(), + cells: visibleCols.map((c) => buildFinanceCell(c.key, r)).toList(), )) .toList(); + // Determine add button label + final addLabel = widget.typeFilter == 'payable' + ? '添加付款' + : widget.typeFilter == 'receivable' + ? '添加收款' + : null; + return Column( children: [ - // Summary bar (only for type-filtered tabs) if (widget.typeFilter.isNotEmpty && records.isNotEmpty) Container( color: AppTheme.background, @@ -272,24 +308,24 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { child: Row( children: [ _SummaryCard( - title: '本期总额', + title: '合计金额', value: '¥${(totalAmount / 10000).toStringAsFixed(2)}万', icon: Icons.account_balance_wallet, color: AppTheme.primary, ), const SizedBox(width: 12), _SummaryCard( - title: '已结清', - value: '¥${(totalPaid / 10000).toStringAsFixed(2)}万', - icon: Icons.check_circle, - color: AppTheme.success, + title: '未结清', + value: '¥${(openAmount / 10000).toStringAsFixed(2)}万', + icon: Icons.pending_actions, + color: AppTheme.danger, ), const SizedBox(width: 12), _SummaryCard( - title: '未结清', - value: '¥${(totalBalance / 10000).toStringAsFixed(2)}万', - icon: Icons.pending_actions, - color: AppTheme.danger, + title: '已结清', + value: '¥${(closedAmount / 10000).toStringAsFixed(2)}万', + icon: Icons.check_circle, + color: AppTheme.success, ), ], ), @@ -316,6 +352,13 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { }, toolbar: Row( children: [ + if (addLabel != null) + ElevatedButton.icon( + onPressed: _showAddDialog, + icon: const Icon(Icons.add, size: 16), + label: Text(addLabel), + ), + if (addLabel != null) const SizedBox(width: 8), OutlinedButton.icon( onPressed: () { final tabName = widget.typeFilter.isEmpty @@ -325,7 +368,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { : '应收账款'; exportExcel( filename: tabName, - headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '备注'], + headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '状态', '备注'], rows: records.map((r) => [ r.recordDate?.substring(0, 10) ?? '', r.typeLabel, @@ -335,6 +378,7 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { : '', r.amount, r.balance, + r.status == 'open' ? '未结清' : '已结清', r.remark ?? '', ]).toList(), ); @@ -368,6 +412,162 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> { } } +// ── 添加付款/收款弹窗 ───────────────────────────────────────── + +class _AddPaymentDialog extends StatefulWidget { + final String typeFilter; // 'payable' | 'receivable' + final VoidCallback onSaved; + final FinanceRepository repo; + + const _AddPaymentDialog({ + required this.typeFilter, + required this.onSaved, + required this.repo, + }); + + @override + State<_AddPaymentDialog> createState() => _AddPaymentDialogState(); +} + +class _AddPaymentDialogState extends State<_AddPaymentDialog> { + final _amountCtrl = TextEditingController(); + final _remarkCtrl = TextEditingController(); + final _partnerCtrl = TextEditingController(); + DateTime _date = DateTime.now(); + bool _saving = false; + + String get _type => widget.typeFilter == 'payable' ? 'payment' : 'receipt'; + String get _title => widget.typeFilter == 'payable' ? '添加付款记录' : '添加收款记录'; + + @override + void dispose() { + _amountCtrl.dispose(); + _remarkCtrl.dispose(); + _partnerCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + final amount = double.tryParse(_amountCtrl.text.trim()); + if (amount == null || amount <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请输入有效金额'), backgroundColor: AppTheme.danger), + ); + return; + } + setState(() => _saving = true); + try { + final body = { + 'type': _type, + 'amount': amount, + 'record_date': '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}', + if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), + }; + await widget.repo.create(body); + if (mounted) { + Navigator.of(context).pop(); + widget.onSaved(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('添加成功'), backgroundColor: AppTheme.success), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('添加失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(_title), + content: SizedBox( + width: 360, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _amountCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: '金额', prefixText: '¥ '), + ), + const SizedBox(height: 12), + InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 30)), + ); + if (picked != null) setState(() => _date = picked); + }, + child: InputDecorator( + decoration: const InputDecoration(labelText: '日期'), + child: Text( + '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 14), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _remarkCtrl, + decoration: const InputDecoration(labelText: '备注(选填)'), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('保存'), + ), + ], + ); + } +} + +// ── Widgets ────────────────────────────────────────────────── + +class _StatusBadge extends StatelessWidget { + final String status; + const _StatusBadge(this.status); + + @override + Widget build(BuildContext context) { + final isOpen = status == 'open'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isOpen ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + isOpen ? '未结清' : '已结清', + style: TextStyle( + color: isOpen ? AppTheme.accent : AppTheme.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} + class _TypeBadge extends StatelessWidget { final String label; const _TypeBadge(this.label); @@ -401,8 +601,7 @@ class _TypeBadge extends StatelessWidget { decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)), child: Text(label, - style: TextStyle( - color: fg, fontSize: 12, fontWeight: FontWeight.w500)), + style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)), ); } } @@ -497,8 +696,7 @@ class _MonthSelector extends StatelessWidget { child: Text(m, style: const TextStyle(fontSize: 13)))) .toList(), onChanged: (v) => onChanged(v!), - style: - const TextStyle(fontSize: 13, color: AppTheme.textPrimary), + style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary), ), ), ); diff --git a/client/lib/screens/inventory/batch_tracking_screen.dart b/client/lib/screens/inventory/batch_tracking_screen.dart deleted file mode 100644 index c02fc26..0000000 --- a/client/lib/screens/inventory/batch_tracking_screen.dart +++ /dev/null @@ -1,411 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import '../../core/theme/app_theme.dart'; -import '../../models/inventory.dart'; -import '../../providers/inventory_provider.dart'; -import '../../widgets/data_table_card.dart'; -import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; -import '../../providers/connectivity_provider.dart'; -import '../../core/utils/export_util.dart'; - -class BatchTrackingScreen extends ConsumerStatefulWidget { - const BatchTrackingScreen({super.key}); - - @override - ConsumerState createState() => - _BatchTrackingScreenState(); -} - -class _BatchTrackingScreenState extends ConsumerState { - int _page = 1; - int _pageSize = 20; - int _total = 0; - late Future> _future; - List _records = []; - - Set _filterStatus = {}; - Set _filterWarehouse = {}; - Set _filterSupplier = {}; - Set _hiddenCols = {}; - - static const _colDefs = [ - ColDef('product', '商品', required: true), - ColDef('spec', '规格', minWidth: 1100), - ColDef('batch', '批次号', minWidth: 1000), - ColDef('order_no', '入库单号', minWidth: 900), - ColDef('supplier', '供应商', minWidth: 1100), - ColDef('warehouse', '仓库'), - ColDef('date', '入库日期', minWidth: 1000), - ColDef('qty', '数量'), - ColDef('price', '单价', minWidth: 900), - ColDef('status', '状态'), - ColDef('buyer', '买家/时间'), - ]; - - @override - void initState() { - super.initState(); - _fetch(); - } - - void _fetch() { - _future = ref - .read(inventoryRepositoryProvider) - .listProducts(page: _page, pageSize: _pageSize) - .then((r) { - _total = r.total; - _records = r.data; - return r.data; - }); - } - - void _refetch() => setState(() => _fetch()); - - List _applyFilters( - List all) { - return all.where((r) { - if (_filterStatus.isNotEmpty) { - final label = r.isSoldOut ? '已卖出' : '在售'; - if (!_filterStatus.contains(label)) return false; - } - if (_filterWarehouse.isNotEmpty) { - if (!_filterWarehouse.contains(r.warehouseName ?? '')) return false; - } - if (_filterSupplier.isNotEmpty) { - if (!_filterSupplier.contains(r.supplierName ?? '')) return false; - } - return true; - }).toList(); - } - - @override - Widget build(BuildContext context) { - // 网络恢复时自动刷新 - ref.listen(networkRecoveryCountProvider, (_, __) => _refetch()); - - return FutureBuilder>( - future: _future, - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - // 有缓存数据时展示缓存,顶部加提示条 - if (_records.isNotEmpty) { - return Column( - children: [ - _OfflineBanner(onRetry: _refetch), - Expanded(child: _buildTable(_applyFilters(_records))), - ], - ); - } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), - const SizedBox(height: 12), - const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)), - const SizedBox(height: 12), - ElevatedButton(onPressed: _refetch, child: const Text('重试')), - ], - ), - ); - } - final filtered = _applyFilters(_records); - return _buildTable(filtered); - }, - ); - } - - Widget _buildTable(List records) { - // Derive filter options from all loaded records - final warehouseOptions = _records - .map((r) => r.warehouseName ?? '') - .where((s) => s.isNotEmpty) - .toSet() - .toList() - ..sort(); - final supplierOptions = _records - .map((r) => r.supplierName ?? '') - .where((s) => s.isNotEmpty) - .toSet() - .toList() - ..sort(); - - // Build visible columns (respect manual hide + responsive auto-hide) - final screenWidth = MediaQuery.of(context).size.width; - final visibleCols = _colDefs - .where((c) => - !_hiddenCols.contains(c.key) && - (c.minWidth == null || screenWidth >= c.minWidth!)) - .toList(); - - final columns = visibleCols.map((c) { - final label = switch (c.key) { - 'status' => FilterableColumnHeader( - text: c.label, - options: const ['在售', '已卖出'], - selected: _filterStatus, - onChanged: (v) => setState(() => _filterStatus = v), - ), - 'warehouse' => FilterableColumnHeader( - text: c.label, - options: warehouseOptions, - selected: _filterWarehouse, - onChanged: (v) => setState(() => _filterWarehouse = v), - ), - 'supplier' => FilterableColumnHeader( - text: c.label, - options: supplierOptions, - selected: _filterSupplier, - onChanged: (v) => setState(() => _filterSupplier = v), - ), - _ => Text(c.label), - }; - return DataColumn( - label: label, - numeric: c.key == 'qty' || c.key == 'price'); - }).toList(); - - final rows = records.isEmpty - ? [ - DataRow( - cells: List.generate( - visibleCols.length, - (i) => i == 1 - ? const DataCell(Text('暂无记录', - style: TextStyle(color: AppTheme.textSecondary))) - : const DataCell(SizedBox()), - ), - ), - ] - : records - .map((r) => DataRow( - cells: visibleCols - .map((c) => _buildCell(c.key, r)) - .toList(), - )) - .toList(); - - return DataTableCard( - totalCount: _total, - page: _page, - pageSize: _pageSize, - onPageChanged: (p) => setState(() { - _page = p; - _fetch(); - }), - onPageSizeChanged: (s) => setState(() { - _pageSize = s; - _page = 1; - _fetch(); - }), - toolbar: Row( - children: [ - const Text('已审核入库商品(含库存与销售状态)', - style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)), - const Spacer(), - OutlinedButton.icon( - onPressed: () => exportExcel( - filename: '商品管理', - headers: ['商品名称', '商品编码', '规格', '批次号', '入库单号', '供应商', '仓库', '入库日期', '数量', '单价', '状态', '买家'], - rows: records.map((r) => [ - r.productName ?? '', - r.productCode ?? '', - r.productSpec ?? '', - r.batchNo ?? '', - r.orderNo ?? '', - r.supplierName ?? '', - r.warehouseName ?? '', - r.orderDate?.substring(0, 10) ?? '', - r.quantity.toInt(), - r.unitPrice, - r.isSoldOut ? '已卖出' : '在售', - r.buyerName ?? '', - ]).toList(), - ), - icon: const Icon(Icons.download, size: 16), - label: const Text('导出'), - ), - const SizedBox(width: 4), - IconButton( - icon: const Icon(Icons.refresh, size: 18), - onPressed: () => setState(() { - _page = 1; - _fetch(); - }), - tooltip: '刷新', - ), - const SizedBox(width: 4), - ColumnToggleButton( - columns: _colDefs, - hidden: _hiddenCols, - onChanged: (v) => setState(() => _hiddenCols = v), - ), - ], - ), - columns: columns, - rows: rows, - ); - } - - DataCell _buildCell(String key, ProductTrackingRecord r) { - final batchText = - (r.batchNo != null && r.batchNo!.isNotEmpty) ? r.batchNo! : null; - switch (key) { - case 'product': - return DataCell( - GestureDetector( - onTap: () => context.push('/products/${r.productId}'), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(r.productName ?? '-', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppTheme.primary, - decoration: TextDecoration.underline, - decorationColor: AppTheme.primary)), - if (r.productCode != null) - Text(r.productCode!, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textSecondary, - fontFamily: 'monospace')), - ], - ), - ), - ); - case 'spec': - return DataCell(Text(r.productSpec ?? '-', - style: const TextStyle(fontSize: 12))); - case 'batch': - return DataCell(batchText != null - ? Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(3), - ), - child: Text(batchText, - style: const TextStyle( - fontSize: 12, - color: AppTheme.primary, - fontFamily: 'monospace')), - ) - : const Text('无批次', - style: TextStyle( - fontSize: 12, color: AppTheme.textSecondary))); - case 'order_no': - return DataCell(Text(r.orderNo ?? '-', - style: const TextStyle( - fontSize: 11, - color: AppTheme.primary, - fontFamily: 'monospace'))); - case 'supplier': - return DataCell(Text(r.supplierName ?? '-', - style: const TextStyle(fontSize: 12))); - case 'warehouse': - return DataCell(Text(r.warehouseName ?? '-', - style: const TextStyle(fontSize: 12))); - case 'date': - return DataCell(Text(r.orderDate?.substring(0, 10) ?? '-', - style: const TextStyle(fontSize: 12))); - case 'qty': - return DataCell(Text( - '${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}', - style: const TextStyle(fontWeight: FontWeight.w500))); - case 'price': - return DataCell(Text('¥${r.unitPrice.toStringAsFixed(2)}', - style: const TextStyle(fontSize: 13))); - case 'status': - return DataCell(_StatusBadge(r.isSoldOut)); - case 'buyer': - return DataCell(r.isSoldOut - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - r.buyerName?.isNotEmpty == true ? r.buyerName! : '未知买家', - style: const TextStyle( - fontSize: 12, fontWeight: FontWeight.w500), - ), - if (r.soldAt != null) - Text( - r.soldAt!.length > 10 - ? r.soldAt!.substring(0, 10) - : r.soldAt!, - style: const TextStyle( - fontSize: 11, color: AppTheme.textSecondary)), - ], - ) - : const Text('-', - style: TextStyle(color: AppTheme.textSecondary))); - default: - return const DataCell(SizedBox()); - } - } -} - -class _StatusBadge extends StatelessWidget { - final bool isSoldOut; - const _StatusBadge(this.isSoldOut); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: isSoldOut - ? AppTheme.textSecondary.withOpacity(0.12) - : AppTheme.success.withOpacity(0.12), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - isSoldOut ? '已卖出' : '在售', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: isSoldOut ? AppTheme.textSecondary : AppTheme.success, - ), - ), - ); - } -} - -class _OfflineBanner extends StatelessWidget { - final VoidCallback onRetry; - const _OfflineBanner({required this.onRetry}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - color: const Color(0xFFFFF8E1), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Row( - children: [ - const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)), - const SizedBox(width: 8), - const Expanded( - child: Text('网络不可用,当前显示离线缓存数据', - style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)), - ), - TextButton( - onPressed: onRetry, - style: TextButton.styleFrom( - foregroundColor: const Color(0xFFF57F17), - padding: const EdgeInsets.symmetric(horizontal: 8)), - child: const Text('重试', style: TextStyle(fontSize: 12)), - ), - ], - ), - ); - } -} diff --git a/client/lib/screens/inventory/inventory_check_screen.dart b/client/lib/screens/inventory/inventory_check_screen.dart index 9bb6040..255f3c0 100644 --- a/client/lib/screens/inventory/inventory_check_screen.dart +++ b/client/lib/screens/inventory/inventory_check_screen.dart @@ -53,6 +53,7 @@ class _InventoryCheckScreenState extends ConsumerState { .read(inventoryRepositoryProvider) .listInventory(warehouseId: wh.id, pageSize: 200); final items = result.data + .where((inv) => inv.productId != null) .map((inv) => _CheckItem(inventory: inv)) .toList(); if (mounted) { @@ -89,7 +90,7 @@ class _InventoryCheckScreenState extends ConsumerState { final actual = double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity; return { - 'product_id': item.inventory.productId, + 'product_id': item.inventory.productId!, 'actual_qty': actual, 'remark': item.remarkCtrl.text.trim(), }; @@ -455,7 +456,7 @@ class _InventoryCheckScreenState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Text(inv.productCode ?? '-', + child: Text(inv.productCode.isEmpty ? '-' : inv.productCode, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, @@ -464,14 +465,14 @@ class _InventoryCheckScreenState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Text(inv.productName ?? '-', + child: Text(inv.productName.isEmpty ? '-' : inv.productName, style: const TextStyle(fontSize: 13), overflow: TextOverflow.ellipsis), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Text(inv.productUnit ?? '-', + child: Text(inv.unit.isEmpty ? '-' : inv.unit, style: const TextStyle(fontSize: 13)), ), Padding( diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index e737b9e..7df107b 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -13,6 +13,10 @@ import '../../widgets/data_table_card.dart'; import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../core/utils/export_util.dart'; +import '../../core/utils/print_util.dart'; +import '../../providers/product_provider.dart'; +import '../../providers/tab_state_provider.dart'; +import '../../providers/shop_provider.dart' show shopInfoProvider; class InventoryListScreen extends ConsumerStatefulWidget { const InventoryListScreen({super.key}); @@ -41,6 +45,50 @@ class _InventoryListScreenState extends ConsumerState { }); } + Future _editRemark(BuildContext context, Inventory item) async { + final ctrl = TextEditingController(text: item.remark); + final saved = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('修改备注'), + content: SizedBox( + width: 360, + child: TextField( + controller: ctrl, + autofocus: true, + maxLines: 3, + decoration: const InputDecoration( + hintText: '输入备注内容…', + border: OutlineInputBorder(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, ctrl.text), + child: const Text('保存'), + ), + ], + ), + ); + ctrl.dispose(); + if (saved == null || !context.mounted) return; + try { + await ref.read(inventoryRepositoryProvider).updateRemark(item.id, saved); + ref.read(inventoryListProvider.notifier).reload(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger), + ); + } + } + } + Future _importInventory(BuildContext context, WidgetRef ref) async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, @@ -94,6 +142,8 @@ class _InventoryListScreenState extends ConsumerState { Widget build(BuildContext context) { return PageScaffold( title: '库存管理', + initialTab: ref.read(inventoryTabProvider), + onTabChanged: (i) => ref.read(inventoryTabProvider.notifier).state = i, tabs: const [ Tab(text: '库存查询'), Tab(text: '库存预警'), @@ -116,10 +166,11 @@ class _InventoryListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), - const SizedBox(height: 12), - const Text('暂无数据,网络不可用', - style: const TextStyle(color: AppTheme.textSecondary)), + const Icon(Icons.error_outline, size: 40, color: AppTheme.textSecondary), + const SizedBox(height: 12), + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.textSecondary), + textAlign: TextAlign.center), const SizedBox(height: 12), ElevatedButton( onPressed: () => @@ -137,7 +188,7 @@ class _InventoryListScreenState extends ConsumerState { // 仓库选项从数据中派生,客户端筛选 final warehouseOptions = items - .map((i) => i.warehouseName ?? '') + .map((i) => i.warehouseName) .where((s) => s.isNotEmpty) .toSet() .toList() @@ -145,7 +196,7 @@ class _InventoryListScreenState extends ConsumerState { final filteredItems = _filterWarehouse.isEmpty ? items : items - .where((i) => _filterWarehouse.contains(i.warehouseName ?? '')) + .where((i) => _filterWarehouse.contains(i.warehouseName)) .toList(); return Column( @@ -158,7 +209,7 @@ class _InventoryListScreenState extends ConsumerState { children: [ _SummaryCard( title: '商品总数', - value: '${items.length}', + value: '${result.total}', unit: '种', icon: Icons.inventory_2, color: AppTheme.primary), @@ -197,16 +248,10 @@ class _InventoryListScreenState extends ConsumerState { label: const Text('发起盘点'), ), const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => _importInventory(context, ref), - icon: const Icon(Icons.upload_file, size: 16), - label: const Text('导入库存'), - ), - const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => exportExcel( filename: '库存查询', - headers: ['商品编码', '商品名称', '品牌', '规格', '仓库', '库存', '安全库存', '状态'], + headers: ['商品编码', '商品名称', '规格', '批次号', '仓库', '库存量', '单价', '生产日期', '供应商', '安全库存', '状态'], rows: filteredItems.map((item) { final status = item.quantity == 0 ? '缺货' @@ -214,12 +259,15 @@ class _InventoryListScreenState extends ConsumerState { ? '库存不足' : '正常'; return [ - item.productCode ?? '', - item.productName ?? '', - item.productBrand ?? '', - item.productSpec ?? '', - item.warehouseName ?? '', - item.quantity.toInt(), + item.productCode.isEmpty ? '' : item.productCode, + item.productName.isEmpty ? '' : item.productName, + item.spec.isEmpty ? '' : item.spec, + item.batchNo.isEmpty ? '' : item.batchNo, + item.warehouseName.isEmpty ? '' : item.warehouseName, + '${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(), + item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '', + item.productionDate ?? '', + item.supplierName.isEmpty ? '' : item.supplierName, item.minStock ?? '', status, ]; @@ -246,8 +294,9 @@ class _InventoryListScreenState extends ConsumerState { columns: [ const DataColumn(label: Text('商品编码')), const DataColumn(label: Text('商品名称')), - const DataColumn(label: Text('品牌')), const DataColumn(label: Text('规格')), + const DataColumn(label: Text('系列')), + const DataColumn(label: Text('批次号')), DataColumn( label: FilterableColumnHeader( text: '仓库', @@ -256,9 +305,13 @@ class _InventoryListScreenState extends ConsumerState { onChanged: (v) => setState(() => _filterWarehouse = v), ), ), - const DataColumn(label: Text('库存'), numeric: true), - const DataColumn(label: Text('安全库存'), numeric: true), + const DataColumn(label: Text('库存量'), numeric: true), + const DataColumn(label: Text('单价'), numeric: true), + const DataColumn(label: Text('生产日期')), + const DataColumn(label: Text('供应商')), + const DataColumn(label: Text('备注')), const DataColumn(label: Text('状态')), + const DataColumn(label: Text('操作')), ], rows: items.isEmpty ? [ @@ -273,6 +326,12 @@ class _InventoryListScreenState extends ConsumerState { DataCell(SizedBox()), DataCell(SizedBox()), DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), + DataCell(SizedBox()), ]) ] : filteredItems @@ -290,25 +349,36 @@ class _InventoryListScreenState extends ConsumerState { }), cells: [ DataCell(Text( - item.productCode ?? '-', + item.productCode.isEmpty ? '-' : item.productCode, style: const TextStyle( fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), - DataCell(SizedBox( - width: 180, - child: Text( - item.productName ?? '-', - overflow: TextOverflow.ellipsis), - )), - DataCell( - Text(item.productBrand ?? '-')), - DataCell( - Text(item.productSpec ?? '-')), - DataCell( - Text(item.warehouseName ?? '-')), + DataCell(item.productId != null + ? GestureDetector( + onTap: () => context.push('/products/${item.productId}'), + child: SizedBox( + width: 180, + child: Text( + item.productName.isEmpty ? '-' : item.productName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppTheme.primary, + decoration: TextDecoration.underline, + decorationColor: AppTheme.primary, + ), + ), + ), + ) + : Text(item.productName.isEmpty ? '-' : item.productName, + overflow: TextOverflow.ellipsis)), + DataCell(Text(item.spec.isEmpty ? '-' : item.spec)), + DataCell(Text(item.series.isEmpty ? '-' : item.series)), + DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), + DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)), DataCell(Text( - item.quantity.toStringAsFixed(0), + '${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(), style: TextStyle( fontWeight: FontWeight.w600, color: item.quantity == 0 @@ -319,10 +389,79 @@ class _InventoryListScreenState extends ConsumerState { ? AppTheme.accent : AppTheme.textPrimary), )), - DataCell(Text(item.minStock != null - ? '${item.minStock}' - : '-')), + DataCell(Text( + item.unitPrice != null + ? '¥${item.unitPrice!.toStringAsFixed(2)}' + : '-', + )), + DataCell(Text(item.productionDate ?? '-')), + DataCell(Text(item.supplierName.isEmpty ? '-' : item.supplierName)), + DataCell( + Tooltip( + message: item.remark.isEmpty ? '' : item.remark, + waitDuration: const Duration(milliseconds: 300), + child: GestureDetector( + onTap: () => _editRemark(context, item), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.remark.isEmpty + ? '—' + : item.remark.length > 4 + ? '${item.remark.substring(0, 4)}…' + : item.remark, + style: TextStyle( + color: item.remark.isEmpty + ? AppTheme.textSecondary + : AppTheme.textPrimary, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.edit_outlined, + size: 12, color: AppTheme.textSecondary), + ], + ), + ), + ), + ), DataCell(_InventoryStatusBadge(item)), + DataCell( + item.productId != null + ? TextButton( + onPressed: () async { + try { + final qrBytes = await ref + .read(productRepositoryProvider) + .getQRCodeBytes(item.productId!); + final shopInfo = ref.read(shopInfoProvider).valueOrNull; + await printProductLabel( + qrBytes: qrBytes, + name: item.productName, + code: item.productCode, + series: item.series.isEmpty ? null : item.series, + spec: item.spec.isEmpty ? null : item.spec, + batchNo: item.batchNo.isEmpty ? null : item.batchNo, + productionDate: item.productionDate, + remark: item.remark.isEmpty ? null : item.remark, + shopName: shopInfo?.name ?? '', + shopAddress: shopInfo?.address ?? '', + shopPhone: shopInfo?.phone ?? '', + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('打印失败:$e'), + backgroundColor: AppTheme.danger), + ); + } + } + }, + child: const Text('打标签', + style: TextStyle(fontSize: 12, color: AppTheme.primary)), + ) + : const SizedBox(), + ), ], )) .toList(), @@ -342,10 +481,11 @@ class _InventoryListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), - const SizedBox(height: 12), - const Text('暂无数据,网络不可用', - style: const TextStyle(color: AppTheme.textSecondary)), + const Icon(Icons.error_outline, size: 40, color: AppTheme.textSecondary), + const SizedBox(height: 12), + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.textSecondary), + textAlign: TextAlign.center), const SizedBox(height: 12), ElevatedButton( onPressed: () => @@ -379,9 +519,9 @@ class _InventoryListScreenState extends ConsumerState { filename: '库存预警', headers: ['商品编码', '商品名称', '仓库', '当前库存', '安全库存', '缺口', '状态'], rows: warnings.map((item) => [ - item.productCode ?? '', - item.productName ?? '', - item.warehouseName ?? '', + item.productCode.isEmpty ? '' : item.productCode, + item.productName.isEmpty ? '' : item.productName, + item.warehouseName.isEmpty ? '' : item.warehouseName, item.quantity.toInt(), item.minStock ?? 0, item.minStock! - item.quantity.toInt(), @@ -423,17 +563,29 @@ class _InventoryListScreenState extends ConsumerState { ? AppTheme.danger.withOpacity(0.05) : AppTheme.accent.withOpacity(0.04)), cells: [ - DataCell(Text(item.productCode ?? '-', + DataCell(Text( + item.productCode.isEmpty ? '-' : item.productCode, style: const TextStyle( fontFamily: 'monospace', fontSize: 12))), - DataCell(SizedBox( - width: 180, - child: Text(item.productName ?? '-', - overflow: TextOverflow.ellipsis), - )), - DataCell( - Text(item.warehouseName ?? '-')), + DataCell(item.productId != null + ? GestureDetector( + onTap: () => context.push('/products/${item.productId}'), + child: SizedBox( + width: 180, + child: Text( + item.productName.isEmpty ? '-' : item.productName, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppTheme.primary, + decoration: TextDecoration.underline, + decorationColor: AppTheme.primary, + )), + ), + ) + : Text(item.productName.isEmpty ? '-' : item.productName, + overflow: TextOverflow.ellipsis)), + DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)), DataCell(Text( item.quantity.toStringAsFixed(0), style: TextStyle( @@ -466,10 +618,11 @@ class _InventoryListScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), - const SizedBox(height: 12), - const Text('暂无数据,网络不可用', - style: const TextStyle(color: AppTheme.textSecondary)), + const Icon(Icons.error_outline, size: 40, color: AppTheme.textSecondary), + const SizedBox(height: 12), + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.textSecondary), + textAlign: TextAlign.center), const SizedBox(height: 12), ElevatedButton( onPressed: () => diff --git a/client/lib/screens/partners/partners_screen.dart b/client/lib/screens/partners/partners_screen.dart index ca54d95..8a5c745 100644 --- a/client/lib/screens/partners/partners_screen.dart +++ b/client/lib/screens/partners/partners_screen.dart @@ -1,3 +1,4 @@ +import '../../core/utils/dialog_util.dart'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -266,7 +267,7 @@ class _PartnersScreenState extends ConsumerState { void _showPartnerDialog(BuildContext context, {required bool isSupplier, Partner? partner}) { - showDialog( + showAppDialog( context: context, builder: (ctx) => _PartnerFormDialog( isSupplier: isSupplier, diff --git a/client/lib/screens/products/product_detail_screen.dart b/client/lib/screens/products/product_detail_screen.dart index 10b2c11..74e62e5 100644 --- a/client/lib/screens/products/product_detail_screen.dart +++ b/client/lib/screens/products/product_detail_screen.dart @@ -1,3 +1,4 @@ +import '../../core/utils/dialog_util.dart'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; @@ -10,6 +11,7 @@ import '../../core/theme/app_theme.dart'; import '../../models/product.dart'; import '../../models/product_image.dart'; import '../../providers/product_provider.dart'; +import '../../providers/shop_provider.dart' show shopInfoProvider; class ProductDetailScreen extends ConsumerStatefulWidget { final int productId; @@ -151,7 +153,7 @@ class _ProductDetailScreenState extends ConsumerState { } void _showQRCode() { - showDialog( + showAppDialog( context: context, builder: (_) => _QRCodeDialog(productId: widget.productId, product: _product!), ); @@ -528,12 +530,16 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> { setState(() { _printing = true; _printStatus = '正在打印...'; }); try { final p = widget.product; + final shopInfo = ref.read(shopInfoProvider).valueOrNull; await printProductLabel( qrBytes: _bytes!, name: p.name, code: p.code, spec: p.spec, series: p.series, + shopName: shopInfo?.name ?? '', + shopAddress: shopInfo?.address ?? '', + shopPhone: shopInfo?.phone ?? '', ); } catch (e) { if (mounted) { diff --git a/client/lib/screens/products/products_screen.dart b/client/lib/screens/products/products_screen.dart index 0bdbce0..e8d72d9 100644 --- a/client/lib/screens/products/products_screen.dart +++ b/client/lib/screens/products/products_screen.dart @@ -1,3 +1,4 @@ +import '../../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/app_theme.dart'; @@ -72,7 +73,7 @@ class _ProductsScreenState extends ConsumerState { searchCtrl: _nameSearchCtrl, hint: '搜索名称/编号', onSearchChanged: () => setState(() => _namePage = 1), - onAdd: () => _showCreateDialog( + onAdd: () => _showOptionDialog( title: '新建商品名称', hasQuantity: false, onSave: (data) => ref.read(productNameListProvider.notifier).create(data), @@ -101,10 +102,18 @@ class _ProductsScreenState extends ConsumerState { style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), DataCell(Text(it.remark ?? '-')), - DataCell(_deleteButton(() => _confirmDelete( - '删除名称「${it.name}」?', - () => ref.read(productNameListProvider.notifier).delete(it.id), - ))), + DataCell(_actionButtons( + onEdit: () => _showOptionDialog( + title: '编辑商品名称', + hasQuantity: false, + initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, + onSave: (data) => ref.read(productNameListProvider.notifier).updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除名称「${it.name}」?', + () => ref.read(productNameListProvider.notifier).delete(it.id), + ), + )), ])).toList(), ); }, @@ -136,7 +145,7 @@ class _ProductsScreenState extends ConsumerState { searchCtrl: _seriesSearchCtrl, hint: '搜索系列/编号', onSearchChanged: () => setState(() => _seriesPage = 1), - onAdd: () => _showCreateDialog( + onAdd: () => _showOptionDialog( title: '新建系列', hasQuantity: false, onSave: (data) => ref.read(productSeriesListProvider.notifier).create(data), @@ -165,10 +174,18 @@ class _ProductsScreenState extends ConsumerState { style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))), DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), DataCell(Text(it.remark ?? '-')), - DataCell(_deleteButton(() => _confirmDelete( - '删除系列「${it.name}」?', - () => ref.read(productSeriesListProvider.notifier).delete(it.id), - ))), + DataCell(_actionButtons( + onEdit: () => _showOptionDialog( + title: '编辑系列', + hasQuantity: false, + initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''}, + onSave: (data) => ref.read(productSeriesListProvider.notifier).updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除系列「${it.name}」?', + () => ref.read(productSeriesListProvider.notifier).delete(it.id), + ), + )), ])).toList(), ); }, @@ -200,7 +217,7 @@ class _ProductsScreenState extends ConsumerState { searchCtrl: _specSearchCtrl, hint: '搜索规格/编号', onSearchChanged: () => setState(() => _specPage = 1), - onAdd: () => _showCreateDialog( + onAdd: () => _showOptionDialog( title: '新建规格', hasQuantity: true, onSave: (data) => ref.read(productSpecListProvider.notifier).create(data), @@ -232,10 +249,18 @@ class _ProductsScreenState extends ConsumerState { DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))), DataCell(Text(it.quantity > 0 ? '${it.quantity}' : '-')), DataCell(Text(it.remark ?? '-')), - DataCell(_deleteButton(() => _confirmDelete( - '删除规格「${it.name}」?', - () => ref.read(productSpecListProvider.notifier).delete(it.id), - ))), + DataCell(_actionButtons( + onEdit: () => _showOptionDialog( + title: '编辑规格', + hasQuantity: true, + initial: {'code': it.code ?? '', 'name': it.name, 'quantity': it.quantity, 'remark': it.remark ?? ''}, + onSave: (data) => ref.read(productSpecListProvider.notifier).updateItem(it.id, data), + ), + onDelete: () => _confirmDelete( + '删除规格「${it.name}」?', + () => ref.read(productSpecListProvider.notifier).delete(it.id), + ), + )), ])).toList(), ); }, @@ -296,10 +321,19 @@ class _ProductsScreenState extends ConsumerState { ); } - Widget _deleteButton(VoidCallback onTap) { - return TextButton( - onPressed: onTap, - child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)), + Widget _actionButtons({required VoidCallback onEdit, required VoidCallback onDelete}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: onEdit, + child: const Text('编辑', style: TextStyle(fontSize: 12)), + ), + TextButton( + onPressed: onDelete, + child: const Text('删除', style: TextStyle(fontSize: 12, color: AppTheme.danger)), + ), + ], ); } @@ -332,18 +366,22 @@ class _ProductsScreenState extends ConsumerState { } } - Future _showCreateDialog({ + Future _showOptionDialog({ required String title, required bool hasQuantity, required Future Function(Map) onSave, + Map? initial, }) async { - final codeCtrl = TextEditingController(); - final nameCtrl = TextEditingController(); - final quantityCtrl = TextEditingController(); - final remarkCtrl = TextEditingController(); + final codeCtrl = TextEditingController(text: initial?['code'] as String? ?? ''); + final nameCtrl = TextEditingController(text: initial?['name'] as String? ?? ''); + final quantityCtrl = TextEditingController( + text: initial != null && (initial['quantity'] as int? ?? 0) > 0 + ? '${initial['quantity']}' + : ''); + final remarkCtrl = TextEditingController(text: initial?['remark'] as String? ?? ''); final formKey = GlobalKey(); - await showDialog( + await showAppDialog( context: context, builder: (ctx) => AlertDialog( title: Text(title), diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index d8790f1..ef07d11 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,3 +1,4 @@ +import '../../core/utils/dialog_util.dart'; import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; @@ -18,6 +19,8 @@ import '../../providers/number_rule_provider.dart'; import '../../providers/update_provider.dart'; import '../../providers/user_provider.dart'; import '../../providers/warehouse_provider.dart'; +import '../../providers/shop_provider.dart'; +import '../../models/shop.dart'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -39,7 +42,7 @@ class _SettingsScreenState extends ConsumerState { @override Widget build(BuildContext context) { return DefaultTabController( - length: 6, + length: 7, child: Column( children: [ Container( @@ -53,6 +56,7 @@ class _SettingsScreenState extends ConsumerState { labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), tabs: [ + Tab(text: '酒行信息'), Tab(text: '用户管理'), Tab(text: '仓库管理'), Tab(text: '编号规则'), @@ -66,6 +70,7 @@ class _SettingsScreenState extends ConsumerState { Expanded( child: TabBarView( children: [ + _buildShopInfoTab(), _buildUsersTab(), _buildWarehousesTab(), _buildNumberRulesTab(), @@ -80,6 +85,95 @@ class _SettingsScreenState extends ConsumerState { ); } + Widget _buildShopInfoTab() { + final asyncShop = ref.watch(shopInfoProvider); + final currentUser = ref.watch(authStateProvider).user; + final isAdmin = + currentUser?.role == 'admin' || currentUser?.role == 'superadmin'; + + return asyncShop.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), + const SizedBox(height: 12), + const Text('暂无数据,网络不可用', + style: TextStyle(color: AppTheme.textSecondary)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => ref.invalidate(shopInfoProvider), + child: const Text('重试'), + ), + ], + ), + ), + data: (shop) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('酒行信息', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text('门店的基本信息,仅管理员可编辑', + style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ShopInfoRow( + label: '门店编号', + value: shop.code.isNotEmpty ? shop.code : '—'), + const Divider(height: 16), + _ShopInfoRow( + label: '门店名称', + value: shop.name.isNotEmpty ? shop.name : '—'), + const Divider(height: 16), + _ShopInfoRow( + label: '门店地址', + value: shop.address.isNotEmpty ? shop.address : '—'), + const Divider(height: 16), + _ShopInfoRow( + label: '联系电话', + value: shop.phone.isNotEmpty ? shop.phone : '—'), + const Divider(height: 16), + _ShopInfoRow( + label: '负责人', + value: + shop.managerName.isNotEmpty ? shop.managerName : '—'), + ], + ), + ), + ), + if (isAdmin) ...[ + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => _showEditShopDialog(context, shop), + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑信息'), + ), + ], + ], + ), + ), + ); + } + + void _showEditShopDialog(BuildContext context, ShopInfo shop) { + showAppDialog( + context: context, + builder: (ctx) => _ShopEditDialog( + shop: shop, + onSaved: () => ref.invalidate(shopInfoProvider), + ), + ); + } + Widget _buildUsersTab() { final asyncUsers = ref.watch(userListProvider); return Column( @@ -330,7 +424,7 @@ class _SettingsScreenState extends ConsumerState { } void _showWarehouseDialog(BuildContext context, {Warehouse? warehouse}) { - showDialog( + showAppDialog( context: context, builder: (ctx) => _WarehouseFormDialog( warehouse: warehouse, @@ -432,7 +526,7 @@ class _SettingsScreenState extends ConsumerState { final prefixCtrl = TextEditingController(text: rule.prefix); final currentNoCtrl = TextEditingController(text: '${rule.currentNo}'); - showDialog( + showAppDialog( context: context, builder: (ctx) => AlertDialog( title: Text('编辑编号规则 — ${rule.typeLabel}'), @@ -778,7 +872,7 @@ class _SettingsScreenState extends ConsumerState { } void _showRenewDialog() { - showDialog( + showAppDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('续费 / 升级授权'), @@ -820,7 +914,7 @@ class _SettingsScreenState extends ConsumerState { void _showFeedbackDialog({required bool isBug}) { final ctrl = TextEditingController(); - showDialog( + showAppDialog( context: context, builder: (ctx) => AlertDialog( title: Text(isBug ? '反馈 Bug' : '功能建议'), @@ -861,7 +955,7 @@ class _SettingsScreenState extends ConsumerState { void _showEditParamDialog( String label, String current, ValueChanged onSave) { final ctrl = TextEditingController(text: current); - showDialog( + showAppDialog( context: context, builder: (ctx) => AlertDialog( title: Text('修改$label'), @@ -891,7 +985,7 @@ class _SettingsScreenState extends ConsumerState { } void _showAddUserDialog(BuildContext context) { - showDialog( + showAppDialog( context: context, builder: (ctx) => _UserFormDialog( onSaved: () => ref.read(userListProvider.notifier).reload(), @@ -900,7 +994,7 @@ class _SettingsScreenState extends ConsumerState { } void _showEditUserDialog(BuildContext context, AppUser user) { - showDialog( + showAppDialog( context: context, builder: (ctx) => _UserFormDialog( user: user, @@ -910,7 +1004,7 @@ class _SettingsScreenState extends ConsumerState { } void _showResetPasswordDialog(BuildContext context, AppUser user) { - showDialog( + showAppDialog( context: context, builder: (ctx) => _ResetPasswordDialog(user: user), ); @@ -1447,6 +1541,7 @@ class _BatchImportWidget extends ConsumerStatefulWidget { class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { bool _loading = false; String? _lastDir; + OverlayEntry? _importBarrier; static const _prefKey = 'import_last_dir'; @@ -1459,6 +1554,8 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { '格式:选项编号 | 选项名称 | 备注'), _ImportSlot('商品规格', '/import/product-specs', '格式:选项编号 | 选项名称 | 单品数量 | 备注'), + _ImportSlot('商品编码', '/import/product-codes', + '格式:商品编码:xxxx'), _ImportSlot('库存', '/import/inventory', '格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'), ]; @@ -1519,10 +1616,44 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { } } + void _showBarrier() { + _importBarrier = OverlayEntry( + builder: (_) => Stack(children: [ + const ModalBarrier(dismissible: false, color: Colors.transparent), + Positioned( + bottom: 24, left: 0, right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + SizedBox(width: 14, height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), + SizedBox(width: 10), + Text('导入中,请勿切换页面…', + style: TextStyle(color: Colors.white, fontSize: 13)), + ]), + ), + ), + ), + ]), + ); + Overlay.of(context, rootOverlay: true).insert(_importBarrier!); + } + + void _removeBarrier() { + _importBarrier?.remove(); + _importBarrier = null; + } + Future _runImport() async { final token = ref.read(authStateProvider).user?.accessToken ?? ''; if (!_slots.any((s) => s.file != null)) return; + _showBarrier(); setState(() { _loading = true; for (final s in _slots) { @@ -1570,7 +1701,10 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { } } - if (mounted) setState(() => _loading = false); + if (mounted) { + _removeBarrier(); + setState(() => _loading = false); + } } @override @@ -1920,39 +2054,196 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { } return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 68, - child: Text(slot.title, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), - ), - const SizedBox(width: 12), - OutlinedButton( - onPressed: _loading ? null : () => _pickFile(index), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text('选择文件', style: TextStyle(fontSize: 13)), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - slot.file?.name ?? '未选择', - style: TextStyle( - fontSize: 13, - color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary, + Row( + children: [ + SizedBox( + width: 68, + child: Text(slot.title, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), ), - overflow: TextOverflow.ellipsis, + const SizedBox(width: 12), + OutlinedButton( + onPressed: _loading ? null : () => _pickFile(index), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('选择文件', style: TextStyle(fontSize: 13)), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + slot.file?.name ?? '未选择', + style: TextStyle( + fontSize: 13, + color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + if (statusWidget != null) statusWidget, + ], + ), + const SizedBox(height: 3), + Padding( + padding: const EdgeInsets.only(left: 80), + child: Text( + slot.hint, + style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary), ), ), - const SizedBox(width: 12), - if (statusWidget != null) statusWidget, ], ), ); } } + +// ── 酒行信息行 ──────────────────────────────────────────── +class _ShopInfoRow extends StatelessWidget { + final String label; + final String value; + const _ShopInfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 88, + child: Text(label, + style: const TextStyle( + fontSize: 14, color: AppTheme.textSecondary)), + ), + Expanded( + child: Text(value, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500)), + ), + ], + ), + ); + } +} + +// ── 编辑酒行信息弹窗 ───────────────────────────────────── +class _ShopEditDialog extends ConsumerStatefulWidget { + final ShopInfo shop; + final VoidCallback onSaved; + const _ShopEditDialog({required this.shop, required this.onSaved}); + + @override + ConsumerState<_ShopEditDialog> createState() => _ShopEditDialogState(); +} + +class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> { + late final TextEditingController _nameCtrl; + late final TextEditingController _addressCtrl; + late final TextEditingController _phoneCtrl; + late final TextEditingController _managerCtrl; + bool _saving = false; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(text: widget.shop.name); + _addressCtrl = TextEditingController(text: widget.shop.address); + _phoneCtrl = TextEditingController(text: widget.shop.phone); + _managerCtrl = TextEditingController(text: widget.shop.managerName); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _addressCtrl.dispose(); + _phoneCtrl.dispose(); + _managerCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() => _saving = true); + try { + await ref.read(shopRepositoryProvider).updateInfo({ + 'name': _nameCtrl.text.trim(), + 'address': _addressCtrl.text.trim(), + 'phone': _phoneCtrl.text.trim(), + 'manager_name': _managerCtrl.text.trim(), + }); + if (mounted) { + Navigator.of(context).pop(); + widget.onSaved(); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('信息已更新'), + backgroundColor: AppTheme.success, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('保存失败:$e'), + backgroundColor: AppTheme.danger, + )); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('编辑酒行信息'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: '门店名称'), + ), + const SizedBox(height: 12), + TextField( + controller: _addressCtrl, + decoration: const InputDecoration(labelText: '门店地址'), + ), + const SizedBox(height: 12), + TextField( + controller: _phoneCtrl, + decoration: const InputDecoration(labelText: '联系电话'), + ), + const SizedBox(height: 12), + TextField( + controller: _managerCtrl, + decoration: const InputDecoration(labelText: '负责人'), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('保存'), + ), + ], + ); + } +} diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index fb1f40f..2bb829a 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -1,3 +1,4 @@ +import '../../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -25,7 +26,7 @@ class _AppShellState extends ConsumerState { BuildContext context, AppUpdateInfo info) { if (_forceDialogShown) return; _forceDialogShown = true; - showDialog( + showAppDialog( context: context, barrierDismissible: false, builder: (ctx) => PopScope( @@ -66,7 +67,6 @@ class _AppShellState extends ConsumerState { _NavItem(icon: Icons.input, label: '入库管理', path: '/stock-in'), _NavItem(icon: Icons.output, label: '出库管理', path: '/stock-out'), _NavItem(icon: Icons.inventory_2, label: '库存管理', path: '/inventory'), - _NavItem(icon: Icons.track_changes, label: '商品详情', path: '/batches'), _NavItem( icon: Icons.account_balance_wallet, label: '财务管理', @@ -87,7 +87,8 @@ class _AppShellState extends ConsumerState { final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0'; - return Scaffold( + return SelectionArea( + child: Scaffold( body: Column( children: [ // Top Bar @@ -363,7 +364,8 @@ class _AppShellState extends ConsumerState { ), ], ), - ); + ), + ); } } @@ -515,7 +517,7 @@ class _ClockWidgetState extends State<_ClockWidget> { } void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'}) { - showDialog( + showAppDialog( context: context, builder: (ctx) => Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index 2f7e054..0c3c5f9 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -72,6 +72,13 @@ class _StockInFormScreenState extends ConsumerState { row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id; row.selectedSeriesId = seriesOpts.where((o) => o.name == item.productSeries).firstOrNull?.id; row.selectedSpecId = specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id; + if (item.batchNo != null) row.batchNoCtrl.text = item.batchNo!; + if (item.productionDate != null) { + row.productionDateCtrl.text = item.productionDate!.length >= 10 + ? item.productionDate!.substring(0, 10) + : item.productionDate!; + row.productionDate = DateTime.tryParse(item.productionDate!); + } _items.add(row); } if (_items.isEmpty) _items.add(_ItemRow()); @@ -112,7 +119,7 @@ class _StockInFormScreenState extends ConsumerState { .read(inventoryRepositoryProvider) .listInventory(warehouseId: warehouseId, pageSize: 500); setState(() { - _inventoryMap = {for (final inv in result.data) inv.productId: inv.quantity}; + _inventoryMap = {for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity}; }); } catch (_) {} } @@ -147,6 +154,53 @@ class _StockInFormScreenState extends ConsumerState { ); return; } + final invalidQtyIndex = _items.indexWhere( + (item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0); + if (invalidQtyIndex >= 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('第 ${invalidQtyIndex + 1} 行数量必须大于 0'), + backgroundColor: AppTheme.danger, + ), + ); + return; + } + + if (!asDraft) { + if (_partnerId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请选择供应商'), backgroundColor: AppTheme.danger), + ); + return; + } + for (int i = 0; i < _items.length; i++) { + final item = _items[i]; + if (item.selectedNameId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('第 ${i + 1} 行请选择商品名称'), backgroundColor: AppTheme.danger), + ); + return; + } + if (item.selectedSeriesId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('第 ${i + 1} 行请选择系列'), backgroundColor: AppTheme.danger), + ); + return; + } + if (item.selectedSpecId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('第 ${i + 1} 行请选择规格'), backgroundColor: AppTheme.danger), + ); + return; + } + if (item.productionDateCtrl.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('第 ${i + 1} 行请填写生产日期'), backgroundColor: AppTheme.danger), + ); + return; + } + } + } setState(() => _submitting = true); @@ -187,11 +241,15 @@ class _StockInFormScreenState extends ConsumerState { final itemsData = _items.map((item) { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; + final batchNo = item.batchNoCtrl.text.trim(); + final productionDate = item.productionDateCtrl.text.trim(); return { 'product_id': item.productId ?? 0, 'quantity': qty, 'unit_price': price, 'total_price': qty * price, + if (batchNo.isNotEmpty) 'batch_no': batchNo, + if (productionDate.isNotEmpty) 'production_date': productionDate, }; }).toList(); @@ -344,6 +402,7 @@ class _StockInFormScreenState extends ConsumerState { ), _FormField( label: '供应商', + required: true, child: asyncSuppliers.when( loading: () => const LinearProgressIndicator(), error: (_, __) => const Text('加载失败'), @@ -358,6 +417,7 @@ class _StockInFormScreenState extends ConsumerState { style: const TextStyle(fontSize: 13)))) .toList(), onChanged: (v) => setState(() => _partnerId = v), + validator: (v) => v == null ? '不能为空' : null, decoration: const InputDecoration(), ), ), @@ -439,22 +499,24 @@ class _StockInFormScreenState extends ConsumerState { const SizedBox(height: 12), Table( columnWidths: const { - 0: FixedColumnWidth(36), - 1: FlexColumnWidth(2.2), - 2: FlexColumnWidth(1.3), - 3: FlexColumnWidth(1.3), - 4: FlexColumnWidth(0.9), - 5: FlexColumnWidth(1.0), - 6: FlexColumnWidth(1.0), - 7: FlexColumnWidth(1.0), - 8: FlexColumnWidth(1.0), - 9: FixedColumnWidth(60), + 0: FixedColumnWidth(36), // 序号 + 1: FlexColumnWidth(1.2), // 商品编码 + 2: FlexColumnWidth(2.0), // 名称 + 3: FlexColumnWidth(1.3), // 系列 + 4: FlexColumnWidth(1.3), // 规格 + 5: FlexColumnWidth(0.9), // 单品数量 + 6: FlexColumnWidth(1.0), // 数量 + 7: FlexColumnWidth(1.0), // 单价 + 8: FlexColumnWidth(1.0), // 金额 + 9: FlexColumnWidth(1.2), // 批次号 + 10: FlexColumnWidth(1.2), // 生产日期 + 11: FixedColumnWidth(60), // 操作 }, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), children: [ - '序号', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '当前库存', '操作', + '序号', '商品编码', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '批次号', '生产日期', '操作', ] .map((h) => Padding( padding: const EdgeInsets.symmetric( @@ -515,6 +577,10 @@ class _StockInFormScreenState extends ConsumerState { ?.where((o) => o.id == item.selectedSpecId) .firstOrNull ?.quantity ?? 0; + final productCode = asyncNames.valueOrNull + ?.where((o) => o.id == item.selectedNameId) + .firstOrNull + ?.code ?? ''; return TableRow( decoration: BoxDecoration( @@ -527,6 +593,12 @@ class _StockInFormScreenState extends ConsumerState { child: Text('${index + 1}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), ), + // 商品编码 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text(productCode, + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), + ), // 名称 Padding( padding: const EdgeInsets.all(4), @@ -561,6 +633,7 @@ class _StockInFormScreenState extends ConsumerState { selectedId: item.selectedSeriesId, hint: '选择系列', dialogTitle: '选择系列', + isRequired: true, onChanged: (v) => setState(() { item.selectedSeriesId = v; item.productId = null; @@ -647,8 +720,51 @@ class _StockInFormScreenState extends ConsumerState { style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), ), - // 当前库存 - _buildInventoryCell(item.productId), + // 批次号 + Padding( + padding: const EdgeInsets.all(4), + child: TextFormField( + controller: item.batchNoCtrl, + decoration: const InputDecoration( + hintText: '选填', + labelText: '批次号', + isDense: true, + ), + style: const TextStyle(fontSize: 13), + onChanged: (_) => setState(() {}), + ), + ), + // 生产日期 + Padding( + padding: const EdgeInsets.all(4), + child: TextFormField( + controller: item.productionDateCtrl, + readOnly: true, + decoration: const InputDecoration( + hintText: '请选择', + labelText: '* 生产日期', + isDense: true, + suffixIcon: Icon(Icons.calendar_today, size: 14), + ), + style: const TextStyle(fontSize: 13), + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: item.productionDate ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + locale: const Locale('zh', 'CN'), + ); + if (date != null) { + setState(() { + item.productionDate = date; + item.productionDateCtrl.text = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + }); + } + }, + ), + ), // 操作 Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), @@ -693,6 +809,7 @@ class _StockInFormScreenState extends ConsumerState { initialDate: _orderDate, firstDate: DateTime(2020), lastDate: DateTime(2030), + locale: const Locale('zh', 'CN'), ); if (date != null) setState(() => _orderDate = date); } @@ -703,12 +820,17 @@ class _ItemRow { int? selectedNameId; int? selectedSeriesId; int? selectedSpecId; - final TextEditingController qtyCtrl = TextEditingController(); + final TextEditingController qtyCtrl = TextEditingController(text: '1'); final TextEditingController priceCtrl = TextEditingController(); + final TextEditingController batchNoCtrl = TextEditingController(); + final TextEditingController productionDateCtrl = TextEditingController(); + DateTime? productionDate; void dispose() { qtyCtrl.dispose(); priceCtrl.dispose(); + batchNoCtrl.dispose(); + productionDateCtrl.dispose(); } } diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index b3816e6..5b9450a 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -1,3 +1,4 @@ +import '../../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -10,7 +11,13 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; import '../../core/utils/export_util.dart'; +import '../../core/utils/print_util.dart'; import '../../providers/inventory_provider.dart'; +import '../../providers/tab_state_provider.dart'; +import '../../providers/product_provider.dart' show productRepositoryProvider; +import '../../repositories/product_repository.dart'; +import '../../providers/finance_provider.dart' show financeRepositoryProvider; +import '../../providers/shop_provider.dart' show shopInfoProvider; class StockInListScreen extends ConsumerStatefulWidget { const StockInListScreen({super.key}); @@ -33,6 +40,7 @@ class _StockInListScreenState extends ConsumerState { ColDef('amount', '金额', minWidth: 800), ColDef('status', '状态'), ColDef('date', '日期', minWidth: 900), + ColDef('reviewed_at', '入库时间', minWidth: 900), ColDef('operator', '入库员', minWidth: 1100), ColDef('reviewer', '审核员', minWidth: 1100), ColDef('actions', '操作', required: true), @@ -63,13 +71,15 @@ class _StockInListScreenState extends ConsumerState { Widget build(BuildContext context) { return PageScaffold( title: '入库管理', + initialTab: ref.read(stockInTabProvider), + onTabChanged: (i) => ref.read(stockInTabProvider.notifier).state = i, tabs: const [ - Tab(text: '入库审核'), Tab(text: '入库单'), + Tab(text: '入库审核'), ], tabViews: [ - _buildListTab(filterStatus: 'pending', showNewButton: true), _buildListTab(filterStatus: 'exclude_pending', showNewButton: false), + _buildListTab(filterStatus: 'pending', showNewButton: true), ], ); } @@ -211,6 +221,12 @@ class _StockInListScreenState extends ConsumerState { return DataCell(StatusBadge(_apiStatusToEnum(o.status))); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); + case 'reviewed_at': + return DataCell(Text(o.reviewedAt != null + ? o.reviewedAt!.length >= 16 + ? o.reviewedAt!.substring(0, 16) + : o.reviewedAt!.substring(0, 10) + : '-')); case 'operator': return DataCell(Text(o.operatorName ?? '-', style: const TextStyle(fontSize: 13))); @@ -218,7 +234,9 @@ class _StockInListScreenState extends ConsumerState { return DataCell(Text(o.reviewerName ?? '-', style: const TextStyle(fontSize: 13))); case 'actions': - return DataCell(Row( + return DataCell(SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( mainAxisSize: MainAxisSize.min, children: [ TextButton( @@ -227,6 +245,40 @@ class _StockInListScreenState extends ConsumerState { style: TextStyle(fontSize: 12, color: AppTheme.primary)), ), + TextButton( + onPressed: () async { + final order = await ref.read(stockInRepositoryProvider).get(o.id); + await printStockInOrder(order); + }, + child: const Text('打印', + style: TextStyle(fontSize: 12, color: AppTheme.primary)), + ), + TextButton( + onPressed: () async { + final order = await ref.read(stockInRepositoryProvider).get(o.id); + if (!context.mounted) return; + final shopInfo = ref.read(shopInfoProvider).valueOrNull; + await showDialog( + context: context, + builder: (_) => _LabelPrintDialog( + order: order, + productRepo: ref.read(productRepositoryProvider), + shopName: shopInfo?.name ?? '', + shopAddress: shopInfo?.address ?? '', + shopPhone: shopInfo?.phone ?? '', + ), + ); + }, + child: const Text('打标签', + style: TextStyle(fontSize: 12, color: AppTheme.primary)), + ), + if (o.status == 'approved') ...[ + TextButton( + onPressed: () => _confirmSettle(context, o.id, 'stock_in'), + child: const Text('结清', + style: TextStyle(fontSize: 12, color: AppTheme.accent)), + ), + ], if (o.status == 'draft') ...[ TextButton( onPressed: () => context.go('/stock-in/edit/${o.id}'), @@ -264,7 +316,7 @@ class _StockInListScreenState extends ConsumerState { ), ], ], - )); + ))); default: return const DataCell(SizedBox()); } @@ -362,7 +414,7 @@ class _StockInListScreenState extends ConsumerState { } Future _showDetail(BuildContext context, int orderId) async { - showDialog( + showAppDialog( context: context, builder: (ctx) => _StockInDetailDialog( orderId: orderId, @@ -386,6 +438,38 @@ class _StockInListScreenState extends ConsumerState { } } + Future _confirmSettle(BuildContext context, int orderId, String refType) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('结清确认'), + content: const Text('确认将该单据的账款标记为已结清?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认结清', style: TextStyle(color: AppTheme.accent)), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + try { + await ref.read(financeRepositoryProvider).closeByRef(refType, orderId); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已结清'), backgroundColor: AppTheme.success), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString()), backgroundColor: AppTheme.danger), + ); + } + } + } + Future _confirmDelete(BuildContext context, StockInOrder o) async { final confirmed = await showDialog( context: context, @@ -553,11 +637,13 @@ class _StockInDetailDialog extends ConsumerStatefulWidget { class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { late Future _future; Map _inventoryMap = {}; + StockInOrder? _loadedOrder; @override void initState() { super.initState(); _future = widget.repository.get(widget.orderId).then((order) async { + if (mounted) setState(() => _loadedOrder = order); try { final result = await ref .read(inventoryRepositoryProvider) @@ -565,7 +651,7 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { if (mounted) { setState(() { _inventoryMap = { - for (final inv in result.data) inv.productId: inv.quantity + for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity }; }); } @@ -600,6 +686,12 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { fontWeight: FontWeight.w600, color: Colors.white)), const Spacer(), + if (_loadedOrder != null) + IconButton( + icon: const Icon(Icons.print_outlined, color: Colors.white), + tooltip: '打印', + onPressed: () => printStockInOrder(_loadedOrder!), + ), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.of(context).pop(), @@ -674,71 +766,38 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { border: TableBorder.all(color: AppTheme.border, width: 0.5), columnWidths: const { 0: FixedColumnWidth(36), - 1: FlexColumnWidth(2.5), - 2: FlexColumnWidth(1.5), + 1: FlexColumnWidth(1.2), + 2: FlexColumnWidth(2.2), 3: FlexColumnWidth(1.5), 4: FlexColumnWidth(1.5), - 5: FlexColumnWidth(1.5), - 6: FlexColumnWidth(1.5), + 5: FlexColumnWidth(1.2), + 6: FlexColumnWidth(1.2), 7: FlexColumnWidth(1.2), }, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), - children: ['序号', '名称', '系列', '规格', '数量', '单价', '金额', '当前库存'] - .asMap().entries.map((e) { - final i = e.key; final h = e.value; - if (i == 0) { - return Padding( + children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额'] + .map((h) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text(h, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), - ); - } - return TableCell( - verticalAlignment: TableCellVerticalAlignment.fill, - child: Container( - color: h == '当前库存' ? const Color(0xFFCFE2FF) : Colors.transparent, - child: Center( - child: Text(h, style: TextStyle( - fontSize: 13, fontWeight: FontWeight.w600, - color: h == '当前库存' ? AppTheme.primary : AppTheme.primaryDark)), - ), - ), - ); - }).toList(), + )).toList(), ), ...o.items.asMap().entries.map((e) { final i = e.key; final item = e.value; - final qty = item.productId != null ? _inventoryMap[item.productId] : null; - final invColor = qty == null - ? AppTheme.textSecondary - : qty <= 0 - ? AppTheme.danger - : AppTheme.primary; return TableRow( decoration: BoxDecoration( color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)), children: [ _TableCell('${i + 1}'), + _TableCell(item.productCode ?? '-'), _TableCell(item.productName ?? '-'), _TableCell(item.productSeries ?? '-'), _TableCell(item.productSpec ?? '-'), _TableCell(item.quantity.toStringAsFixed(3)), _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), _TableCell('¥${item.totalPrice.toStringAsFixed(2)}'), - TableCell( - verticalAlignment: TableCellVerticalAlignment.fill, - child: Container( - color: const Color(0xFFEBF3FF), - child: Center( - child: Text( - qty != null ? qty.toStringAsFixed(0) : '-', - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: invColor), - ), - ), - ), - ), ], ); }), @@ -837,3 +896,149 @@ class _StatusFilterDropdown extends StatelessWidget { ); } } + +class _LabelPrintDialog extends StatefulWidget { + final StockInOrder order; + final ProductRepository productRepo; + final String shopName; + final String shopAddress; + final String shopPhone; + const _LabelPrintDialog({ + required this.order, + required this.productRepo, + this.shopName = '', + this.shopAddress = '', + this.shopPhone = '', + }); + + @override + State<_LabelPrintDialog> createState() => _LabelPrintDialogState(); +} + +class _LabelPrintDialogState extends State<_LabelPrintDialog> { + late final List _selected; + bool _printing = false; + String _status = ''; + + @override + void initState() { + super.initState(); + _selected = List.filled(widget.order.items.length, true); + } + + Future _print() async { + setState(() { _printing = true; _status = '正在打印...'; }); + int done = 0; + for (int i = 0; i < widget.order.items.length; i++) { + if (!_selected[i]) continue; + final item = widget.order.items[i]; + try { + final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId); + await printProductLabel( + qrBytes: qrBytes, + name: item.productName ?? '', + code: item.productCode ?? '', + series: item.productSeries, + spec: item.productSpec, + batchNo: item.batchNo, + productionDate: item.productionDate, + shopName: widget.shopName, + shopAddress: widget.shopAddress, + shopPhone: widget.shopPhone, + ); + done++; + if (mounted) setState(() => _status = '已打印 $done 张...'); + } catch (e) { + if (mounted) setState(() => _status = '第${i + 1}行打印失败:$e'); + } + } + if (mounted) setState(() { _printing = false; _status = '完成,共打印 $done 张'; }); + } + + @override + Widget build(BuildContext context) { + final items = widget.order.items; + return Dialog( + child: Container( + width: 520, + constraints: const BoxConstraints(maxHeight: 520), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + const Text('打印商品标签', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final item = items[i]; + return CheckboxListTile( + value: _selected[i], + onChanged: _printing + ? null + : (v) => setState(() => _selected[i] = v ?? false), + title: Text( + '${item.productCode ?? ''} ${item.productName ?? ''}', + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + '${item.productSeries ?? ''} ${item.productSpec ?? ''}', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + ); + }, + ), + ), + if (_status.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text(_status, + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: (_printing || !_selected.contains(true)) ? null : _print, + icon: const Icon(Icons.print_outlined, size: 16), + label: Text(_printing ? '打印中...' : '打印选中'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/screens/stock_out/stock_out_form_screen.dart b/client/lib/screens/stock_out/stock_out_form_screen.dart index de9914c..b6ce590 100644 --- a/client/lib/screens/stock_out/stock_out_form_screen.dart +++ b/client/lib/screens/stock_out/stock_out_form_screen.dart @@ -8,11 +8,51 @@ import '../../core/utils/print_util.dart'; import '../../models/stock_out.dart'; import '../../providers/inventory_provider.dart'; import '../../providers/partner_provider.dart'; -import '../../providers/product_option_provider.dart'; -import '../../providers/product_provider.dart'; import '../../providers/stock_out_provider.dart'; import '../../providers/warehouse_provider.dart'; -import '../../widgets/searchable_option_field.dart'; + +// Aggregated per-product inventory item for the picker dialog +class _PickerItem { + final int productId; + final String productCode; + final String productName; + final String series; + final String spec; + final String unit; + final double? unitPrice; + final double availableQty; + const _PickerItem({ + required this.productId, + required this.productCode, + required this.productName, + required this.series, + required this.spec, + required this.unit, + this.unitPrice, + required this.availableQty, + }); +} + +class _ItemRow { + int? productId; + final String productCode; + final String productName; + final String series; + final String spec; + final double? unitPrice; + final TextEditingController qtyCtrl; + + _ItemRow({ + this.productId, + this.productCode = '', + this.productName = '', + this.series = '', + this.spec = '', + this.unitPrice, + }) : qtyCtrl = TextEditingController(text: '1'); + + void dispose() => qtyCtrl.dispose(); +} class StockOutFormScreen extends ConsumerStatefulWidget { final int? editOrderId; @@ -31,6 +71,7 @@ class _StockOutFormScreenState extends ConsumerState { bool _submitting = false; bool _loadingEdit = false; Map _inventoryMap = {}; + List<_PickerItem> _inventoryPickerItems = []; StockOutOrder? _loadedOrder; final List<_ItemRow> _items = []; @@ -42,18 +83,14 @@ class _StockOutFormScreenState extends ConsumerState { super.initState(); if (_isEdit) { _loadEditOrder(); - } else { - _items.add(_ItemRow()); } + // New orders start with empty list; user adds via dialog } Future _loadEditOrder() async { setState(() => _loadingEdit = true); try { final order = await ref.read(stockOutRepositoryProvider).get(widget.editOrderId!); - final nameOpts = await ref.read(productNameListProvider.future); - final seriesOpts = await ref.read(productSeriesListProvider.future); - final specOpts = await ref.read(productSpecListProvider.future); setState(() { _loadedOrder = order; @@ -65,16 +102,17 @@ class _StockOutFormScreenState extends ConsumerState { _remarkCtrl.text = order.remark ?? ''; _items.clear(); for (final item in order.items) { - final row = _ItemRow(); - row.productId = item.productId; + final row = _ItemRow( + productId: item.productId, + productCode: item.productCode ?? '', + productName: item.productName ?? '', + series: item.productSeries ?? '', + spec: item.productSpec ?? '', + unitPrice: item.unitPrice, + ); row.qtyCtrl.text = item.quantity.toStringAsFixed(0); - row.priceCtrl.text = item.unitPrice.toStringAsFixed(2); - row.selectedNameId = nameOpts.where((o) => o.name == item.productName).firstOrNull?.id; - row.selectedSeriesId = seriesOpts.where((o) => o.name == item.productSeries).firstOrNull?.id; - row.selectedSpecId = specOpts.where((o) => o.name == item.productSpec).firstOrNull?.id; _items.add(row); } - if (_items.isEmpty) _items.add(_ItemRow()); }); if (_warehouseId != null) await _loadInventory(_warehouseId!); } catch (e) { @@ -101,8 +139,7 @@ class _StockOutFormScreenState extends ConsumerState { double total = 0; for (final item in _items) { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; - final price = double.tryParse(item.priceCtrl.text) ?? 0; - total += qty * price; + total += qty * (item.unitPrice ?? 0); } return total; } @@ -111,15 +148,69 @@ class _StockOutFormScreenState extends ConsumerState { try { final result = await ref .read(inventoryRepositoryProvider) - .listInventory(warehouseId: warehouseId, pageSize: 500); + .listInventory(warehouseId: warehouseId, pageSize: 1000); + final Map productMap = {}; + for (final inv in result.data.where((inv) => inv.productId != null)) { + final pid = inv.productId!; + if (productMap.containsKey(pid)) { + final existing = productMap[pid]!; + productMap[pid] = _PickerItem( + productId: pid, + productCode: existing.productCode, + productName: existing.productName, + series: existing.series, + spec: existing.spec, + unit: existing.unit, + unitPrice: existing.unitPrice ?? inv.unitPrice, + availableQty: existing.availableQty + inv.quantity, + ); + } else { + productMap[pid] = _PickerItem( + productId: pid, + productCode: inv.productCode, + productName: inv.productName, + series: inv.series, + spec: inv.spec, + unit: inv.unit, + unitPrice: inv.unitPrice, + availableQty: inv.quantity, + ); + } + } setState(() { - _inventoryMap = {for (final inv in result.data) inv.productId: inv.quantity}; + _inventoryPickerItems = productMap.values.toList(); + _inventoryMap = { + for (final item in _inventoryPickerItems) item.productId: item.availableQty + }; }); } catch (_) {} } - void _addItem() { - setState(() => _items.add(_ItemRow())); + Future _addItem() async { + if (_warehouseId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先选择出库仓库'), backgroundColor: AppTheme.danger), + ); + return; + } + final selected = await showDialog>( + context: context, + builder: (_) => _InventoryPickerDialog(items: _inventoryPickerItems), + ); + if (selected == null || selected.isEmpty) return; + setState(() { + for (final item in selected) { + if (_items.any((row) => row.productId == item.productId)) continue; + _items.add(_ItemRow( + productId: item.productId, + productCode: item.productCode, + productName: item.productName, + series: item.series, + spec: item.spec, + unitPrice: item.unitPrice, + )); + } + }); } void _removeItem(int index) { @@ -148,45 +239,23 @@ class _StockOutFormScreenState extends ConsumerState { ); return; } + final invalidQtyIndex = _items.indexWhere( + (item) => (double.tryParse(item.qtyCtrl.text) ?? 0) <= 0); + if (invalidQtyIndex >= 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('第 ${invalidQtyIndex + 1} 行数量必须大于 0'), + backgroundColor: AppTheme.danger, + ), + ); + return; + } setState(() => _submitting = true); - final nameOpts = ref.read(productNameListProvider).valueOrNull ?? []; - final seriesOpts = ref.read(productSeriesListProvider).valueOrNull ?? []; - final specOpts = ref.read(productSpecListProvider).valueOrNull ?? []; - - for (final item in _items) { - if (item.productId == null) { - final name = nameOpts.where((o) => o.id == item.selectedNameId).firstOrNull?.name ?? ''; - final series = seriesOpts.where((o) => o.id == item.selectedSeriesId).firstOrNull?.name ?? ''; - final spec = specOpts.where((o) => o.id == item.selectedSpecId).firstOrNull?.name ?? ''; - if (name.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请选择商品名称'), backgroundColor: AppTheme.danger), - ); - setState(() => _submitting = false); - return; - } - try { - final product = await ref - .read(productRepositoryProvider) - .findOrCreate(name: name, series: series, spec: spec); - item.productId = product.id; - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('商品查找失败:$e'), backgroundColor: AppTheme.danger), - ); - setState(() => _submitting = false); - } - return; - } - } - } - final itemsData = _items.map((item) { final qty = double.tryParse(item.qtyCtrl.text) ?? 0; - final price = double.tryParse(item.priceCtrl.text) ?? 0; + final price = item.unitPrice ?? 0; return { 'product_id': item.productId ?? 0, 'quantity': qty, @@ -439,22 +508,21 @@ class _StockOutFormScreenState extends ConsumerState { const SizedBox(height: 12), Table( columnWidths: const { - 0: FixedColumnWidth(36), - 1: FlexColumnWidth(2.2), - 2: FlexColumnWidth(1.3), - 3: FlexColumnWidth(1.3), - 4: FlexColumnWidth(0.9), - 5: FlexColumnWidth(1.0), - 6: FlexColumnWidth(1.0), - 7: FlexColumnWidth(1.0), - 8: FlexColumnWidth(1.0), - 9: FixedColumnWidth(60), + 0: FixedColumnWidth(36), // 序号 + 1: FlexColumnWidth(1.2), // 商品编码 + 2: FlexColumnWidth(2.0), // 商品名称 + 3: FlexColumnWidth(1.2), // 系列 + 4: FlexColumnWidth(1.2), // 规格 + 5: FlexColumnWidth(1.0), // 单价 + 6: FlexColumnWidth(0.8), // 数量 + 7: FlexColumnWidth(1.0), // 金额 + 8: FixedColumnWidth(48), // 操作 }, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), children: [ - '序号', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '当前库存', '操作', + '序号', '商品编码', '商品名称', '系列', '规格', '单价', '数量', '金额', '操作', ] .map((h) => Padding( padding: const EdgeInsets.symmetric( @@ -470,6 +538,12 @@ class _StockOutFormScreenState extends ConsumerState { ...List.generate(_items.length, (i) => _buildItemRow(i)), ], ), + if (_items.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: Text('暂无商品,点击"添加商品"从库存中选择', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 13))), + ), const Divider(height: 1), Padding( padding: const EdgeInsets.only(top: 12), @@ -505,16 +579,10 @@ class _StockOutFormScreenState extends ConsumerState { TableRow _buildItemRow(int index) { final item = _items[index]; - final asyncNames = ref.watch(productNameListProvider); - final asyncSeries = ref.watch(productSeriesListProvider); - final asyncSpecs = ref.watch(productSpecListProvider); final qty = double.tryParse(item.qtyCtrl.text) ?? 0; - final price = double.tryParse(item.priceCtrl.text) ?? 0; + final price = item.unitPrice ?? 0; final amount = qty * price; - final specQty = asyncSpecs.valueOrNull - ?.where((o) => o.id == item.selectedSpecId) - .firstOrNull - ?.quantity ?? 0; + final available = _inventoryMap[item.productId]; return TableRow( decoration: BoxDecoration( @@ -522,84 +590,17 @@ class _StockOutFormScreenState extends ConsumerState { ), children: [ // 序号 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text('${index + 1}', - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), - ), - // 名称 - Padding( - padding: const EdgeInsets.all(4), - child: asyncNames.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (names) => SearchableOptionField( - options: names - .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) - .toList(), - selectedId: item.selectedNameId, - hint: '选择名称', - dialogTitle: '选择商品名称', - isRequired: true, - onChanged: (v) => setState(() { - item.selectedNameId = v; - item.productId = null; - }), - ), - ), - ), + _cell(Text('${index + 1}', style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary))), + // 商品编码 + _cell(Text(item.productCode, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), + // 商品名称 + _cell(Text(item.productName, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis)), // 系列 - Padding( - padding: const EdgeInsets.all(4), - child: asyncSeries.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (series) => SearchableOptionField( - options: series - .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) - .toList(), - selectedId: item.selectedSeriesId, - hint: '选择系列', - dialogTitle: '选择系列', - onChanged: (v) => setState(() { - item.selectedSeriesId = v; - item.productId = null; - }), - ), - ), - ), + _cell(Text(item.series, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), // 规格 - Padding( - padding: const EdgeInsets.all(4), - child: asyncSpecs.when( - loading: () => const LinearProgressIndicator(), - error: (_, __) => const Text('加载失败'), - data: (specs) => SearchableOptionField( - options: specs - .map((o) => OptionItem(id: o.id, name: o.name, code: o.code)) - .toList(), - selectedId: item.selectedSpecId, - hint: '选择规格', - dialogTitle: '选择规格', - isRequired: true, - onChanged: (v) => setState(() { - item.selectedSpecId = v; - item.productId = null; - }), - ), - ), - ), - // 单品数量 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text( - specQty > 0 ? '$specQty' : '-', - style: TextStyle( - fontSize: 13, - color: specQty > 0 ? Colors.black87 : AppTheme.textSecondary, - ), - ), - ), + _cell(Text(item.spec, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))), + // 单价 + _cell(Text(price > 0 ? '¥${price.toStringAsFixed(2)}' : '-', style: const TextStyle(fontSize: 13))), // 数量 Padding( padding: const EdgeInsets.all(4), @@ -608,29 +609,7 @@ class _StockOutFormScreenState extends ConsumerState { decoration: const InputDecoration(hintText: '0', isDense: true), style: const TextStyle(fontSize: 13), keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) - ], - onChanged: (_) => setState(() {}), - validator: (v) { - if (v == null || v.isEmpty) return '不能为空'; - if ((double.tryParse(v) ?? 0) <= 0) return '>0'; - return null; - }, - ), - ), - // 单价 - Padding( - padding: const EdgeInsets.all(4), - child: TextFormField( - controller: item.priceCtrl, - decoration: const InputDecoration( - hintText: '0.00', prefixText: '¥', isDense: true), - style: const TextStyle(fontSize: 13), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) - ], + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], onChanged: (_) => setState(() {}), validator: (v) { if (v == null || v.isEmpty) return '不能为空'; @@ -640,21 +619,13 @@ class _StockOutFormScreenState extends ConsumerState { ), ), // 金额 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text( - '¥${amount.toStringAsFixed(2)}', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), - ), - ), - // 当前库存 - _buildInventoryCell(item.productId), + _cell(Text('¥${amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))), // 操作 Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: IconButton( icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger), - onPressed: _items.length > 1 ? () => _removeItem(index) : null, + onPressed: () => _removeItem(index), tooltip: '删除', padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), @@ -664,14 +635,16 @@ class _StockOutFormScreenState extends ConsumerState { ); } - Widget _buildInventoryCell(int? productId) { - if (productId == null) { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)), + Widget _cell(Widget child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: child, ); + + Widget _buildInventoryCell(int? productId, [double? available]) { + if (productId == null) { + return _cell(const Text('-', style: TextStyle(color: AppTheme.textSecondary, fontSize: 13))); } - final qty = _inventoryMap[productId]; + final qty = available ?? _inventoryMap[productId]; final text = qty != null ? qty.toStringAsFixed(0) : (_warehouseId == null ? '选仓库后显示' : '-'); @@ -680,11 +653,7 @@ class _StockOutFormScreenState extends ConsumerState { : qty <= 0 ? AppTheme.danger : AppTheme.primary; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text(text, - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)), - ); + return _cell(Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color))); } Future _pickDate() async { @@ -693,25 +662,12 @@ class _StockOutFormScreenState extends ConsumerState { initialDate: _orderDate, firstDate: DateTime(2020), lastDate: DateTime(2030), + locale: const Locale('zh', 'CN'), ); if (date != null) setState(() => _orderDate = date); } } -class _ItemRow { - int? productId; - int? selectedNameId; - int? selectedSeriesId; - int? selectedSpecId; - final TextEditingController qtyCtrl = TextEditingController(); - final TextEditingController priceCtrl = TextEditingController(); - - void dispose() { - qtyCtrl.dispose(); - priceCtrl.dispose(); - } -} - class _FormField extends StatelessWidget { final String label; final Widget child; @@ -747,3 +703,233 @@ class _FormField extends StatelessWidget { ); } } + +class _InventoryPickerDialog extends StatefulWidget { + final List<_PickerItem> items; + const _InventoryPickerDialog({required this.items}); + + @override + State<_InventoryPickerDialog> createState() => _InventoryPickerDialogState(); +} + +class _InventoryPickerDialogState extends State<_InventoryPickerDialog> { + final _searchCtrl = TextEditingController(); + String _search = ''; + final Set _selected = {}; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + List<_PickerItem> get _filtered { + if (_search.isEmpty) return widget.items; + final q = _search.toLowerCase(); + return widget.items + .where((item) => + item.productName.toLowerCase().contains(q) || + item.productCode.toLowerCase().contains(q) || + item.series.toLowerCase().contains(q)) + .toList(); + } + + @override + Widget build(BuildContext context) { + final filtered = _filtered; + final allSelected = filtered.isNotEmpty && filtered.every((e) => _selected.contains(e.productId)); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: SizedBox( + width: 820, + height: 580, + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const Text('选择商品', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Text('已选 ${_selected.length} 个', + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ), + const Divider(height: 1), + // Search bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: TextField( + controller: _searchCtrl, + decoration: const InputDecoration( + hintText: '搜索商品编码、名称或系列', + prefixIcon: Icon(Icons.search, size: 18), + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + onChanged: (v) => setState(() => _search = v), + ), + ), + // Table header + Container( + color: const Color(0xFFF0F4FF), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + const SizedBox(width: 44), + _headerCell('商品编码', 110), + _headerCell('商品名称', 200), + _headerCell('系列', 110), + _headerCell('规格', 130), + _headerCell('单价', 90), + _headerCell('库存', 90), + ], + ), + ), + const Divider(height: 1), + // List + Expanded( + child: filtered.isEmpty + ? const Center( + child: Text('没有匹配的商品', + style: TextStyle(color: AppTheme.textSecondary)), + ) + : ListView.separated( + itemCount: filtered.length, + separatorBuilder: (_, __) => + const Divider(height: 1, indent: 16, endIndent: 16), + itemBuilder: (context, i) { + final item = filtered[i]; + final sel = _selected.contains(item.productId); + return InkWell( + onTap: () => setState(() { + if (sel) { + _selected.remove(item.productId); + } else { + _selected.add(item.productId); + } + }), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + child: Row( + children: [ + SizedBox( + width: 44, + child: Checkbox( + value: sel, + onChanged: (v) => setState(() { + if (v == true) { + _selected.add(item.productId); + } else { + _selected.remove(item.productId); + } + }), + ), + ), + _dataCell(item.productCode, 110, + color: AppTheme.textSecondary), + _dataCell(item.productName, 200, bold: true), + _dataCell(item.series, 110, + color: AppTheme.textSecondary), + _dataCell(item.spec, 130, + color: AppTheme.textSecondary), + _dataCell( + item.unitPrice != null + ? '¥${item.unitPrice!.toStringAsFixed(2)}' + : '-', + 90, + ), + _dataCell( + item.availableQty.toStringAsFixed(0), + 90, + color: item.availableQty <= 0 + ? AppTheme.danger + : AppTheme.primary, + bold: true, + ), + ], + ), + ), + ); + }, + ), + ), + const Divider(height: 1), + // Footer + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + TextButton( + onPressed: () => setState(() { + if (allSelected) { + for (final e in filtered) { + _selected.remove(e.productId); + } + } else { + _selected.addAll(filtered.map((e) => e.productId)); + } + }), + child: Text(allSelected ? '取消全选' : '全选当前'), + ), + const Spacer(), + OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _selected.isEmpty + ? null + : () { + final result = widget.items + .where((item) => _selected.contains(item.productId)) + .toList(); + Navigator.pop(context, result); + }, + child: Text('确定添加(${_selected.length})'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _headerCell(String text, double width) => SizedBox( + width: width, + child: Text(text, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + ); + + Widget _dataCell(String text, double width, {Color? color, bool bold = false}) => + SizedBox( + width: width, + child: Text( + text, + style: TextStyle( + fontSize: 13, + color: color, + fontWeight: bold ? FontWeight.w600 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ); +} diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index c386db7..a17e669 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1,3 +1,5 @@ +import '../../repositories/product_repository.dart'; +import '../../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -10,7 +12,11 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto import '../../widgets/page_scaffold.dart'; import '../../widgets/status_badge.dart'; import '../../core/utils/export_util.dart'; +import '../../core/utils/print_util.dart'; import '../../providers/inventory_provider.dart'; +import '../../providers/tab_state_provider.dart'; +import '../../providers/product_provider.dart'; +import '../../providers/finance_provider.dart' show financeRepositoryProvider; class StockOutListScreen extends ConsumerStatefulWidget { const StockOutListScreen({super.key}); @@ -34,6 +40,8 @@ class _StockOutListScreenState extends ConsumerState { ColDef('amount', '金额', minWidth: 800), ColDef('status', '状态'), ColDef('date', '日期', minWidth: 900), + ColDef('reviewed_at', '出库时间', minWidth: 900), + ColDef('created_at', '创建时间', minWidth: 900), ColDef('operator', '出库员', minWidth: 1100), ColDef('reviewer', '审核员', minWidth: 1100), ColDef('actions', '操作', required: true), @@ -64,13 +72,15 @@ class _StockOutListScreenState extends ConsumerState { Widget build(BuildContext context) { return PageScaffold( title: '出库管理', + initialTab: ref.read(stockOutTabProvider), + onTabChanged: (i) => ref.read(stockOutTabProvider.notifier).state = i, tabs: const [ - Tab(text: '出库审核'), Tab(text: '出库单'), + Tab(text: '出库审核'), ], tabViews: [ - _buildListTab(filterStatus: 'pending', showNewButton: true), _buildListTab(filterStatus: 'exclude_pending', showNewButton: false), + _buildListTab(filterStatus: 'pending', showNewButton: true), ], ); } @@ -212,6 +222,18 @@ class _StockOutListScreenState extends ConsumerState { return DataCell(StatusBadge(_apiStatusToEnum(o.status))); case 'date': return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); + case 'reviewed_at': + return DataCell(Text(o.reviewedAt != null + ? o.reviewedAt!.length >= 16 + ? o.reviewedAt!.substring(0, 16) + : o.reviewedAt!.substring(0, 10) + : '-')); + case 'created_at': + return DataCell(Text(o.createdAt != null + ? o.createdAt!.length >= 16 + ? o.createdAt!.substring(0, 16) + : o.createdAt!.substring(0, 10) + : '-')); case 'operator': return DataCell(Text(o.operatorName ?? '-', style: const TextStyle(fontSize: 13))); @@ -219,7 +241,9 @@ class _StockOutListScreenState extends ConsumerState { return DataCell(Text(o.reviewerName ?? '-', style: const TextStyle(fontSize: 13))); case 'actions': - return DataCell(Row( + return DataCell(SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( mainAxisSize: MainAxisSize.min, children: [ TextButton( @@ -228,6 +252,21 @@ class _StockOutListScreenState extends ConsumerState { style: TextStyle(fontSize: 12, color: AppTheme.primary)), ), + TextButton( + onPressed: () async { + final order = await ref.read(stockOutRepositoryProvider).get(o.id); + await printStockOutOrder(order); + }, + child: const Text('打印', + style: TextStyle(fontSize: 12, color: AppTheme.primary)), + ), + if (o.status == 'approved') ...[ + TextButton( + onPressed: () => _confirmSettle(context, o.id, 'stock_out'), + child: const Text('结清', + style: TextStyle(fontSize: 12, color: AppTheme.accent)), + ), + ], if (o.status == 'draft') ...[ TextButton( onPressed: () => context.go('/stock-out/edit/${o.id}'), @@ -265,7 +304,7 @@ class _StockOutListScreenState extends ConsumerState { ), ], ], - )); + ))); default: return const DataCell(SizedBox()); } @@ -363,7 +402,7 @@ class _StockOutListScreenState extends ConsumerState { } Future _showDetail(BuildContext context, int orderId) async { - showDialog( + showAppDialog( context: context, builder: (ctx) => _StockOutDetailDialog( orderId: orderId, @@ -387,6 +426,38 @@ class _StockOutListScreenState extends ConsumerState { } } + Future _confirmSettle(BuildContext context, int orderId, String refType) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('结清确认'), + content: const Text('确认将该单据的账款标记为已结清?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认结清', style: TextStyle(color: AppTheme.accent)), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + try { + await ref.read(financeRepositoryProvider).closeByRef(refType, orderId); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已结清'), backgroundColor: AppTheme.success), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString()), backgroundColor: AppTheme.danger), + ); + } + } + } + Future _confirmDelete(BuildContext context, StockOutOrder o) async { final confirmed = await showDialog( context: context, @@ -558,11 +629,13 @@ class _StockOutDetailDialog extends ConsumerStatefulWidget { class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { late Future _future; Map _inventoryMap = {}; + StockOutOrder? _loadedOrder; @override void initState() { super.initState(); _future = widget.repository.get(widget.orderId).then((order) async { + if (mounted) setState(() => _loadedOrder = order); try { final result = await ref .read(inventoryRepositoryProvider) @@ -570,7 +643,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { if (mounted) { setState(() { _inventoryMap = { - for (final inv in result.data) inv.productId: inv.quantity + for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity }; }); } @@ -605,6 +678,12 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { fontWeight: FontWeight.w600, color: Colors.white)), const Spacer(), + if (_loadedOrder != null) + IconButton( + icon: const Icon(Icons.print_outlined, color: Colors.white), + tooltip: '打印', + onPressed: () => printStockOutOrder(_loadedOrder!), + ), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.of(context).pop(), @@ -678,82 +757,43 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { border: TableBorder.all(color: AppTheme.border, width: 0.5), columnWidths: const { 0: FixedColumnWidth(36), - 1: FlexColumnWidth(2.5), - 2: FlexColumnWidth(1.5), + 1: FlexColumnWidth(1.2), + 2: FlexColumnWidth(2.2), 3: FlexColumnWidth(1.5), 4: FlexColumnWidth(1.5), - 5: FlexColumnWidth(1.5), - 6: FlexColumnWidth(1.5), + 5: FlexColumnWidth(1.2), + 6: FlexColumnWidth(1.2), 7: FlexColumnWidth(1.2), }, children: [ TableRow( decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), - children: ['序号', '名称', '系列', '规格', '数量', '单价', '金额', '当前库存'] - .asMap() - .entries - .map((e) { - final i = e.key; - final h = e.value; - // 第一个格子用 Padding 提供行高参照 - if (i == 0) { - return Padding( + children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额'] + .map((h) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: Text(h, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), - ); - } - return TableCell( - verticalAlignment: TableCellVerticalAlignment.fill, - child: Container( - color: h == '当前库存' ? const Color(0xFFCFE2FF) : Colors.transparent, - child: Center( - child: Text(h, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: h == '当前库存' ? AppTheme.primary : AppTheme.primaryDark)), - ), - ), - ); - }) + )) .toList(), ), ...o.items.asMap().entries.map((e) { final i = e.key; final item = e.value; - final qty = item.productId != null ? _inventoryMap[item.productId] : null; - final invColor = qty == null - ? AppTheme.textSecondary - : qty <= 0 - ? AppTheme.danger - : AppTheme.primary; return TableRow( decoration: BoxDecoration( color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)), children: [ _TableCell('${i + 1}'), + _TableCell(item.productCode ?? '-'), _TableCell(item.productName ?? '-'), _TableCell(item.productSeries ?? '-'), _TableCell(item.productSpec ?? '-'), _TableCell(item.quantity.toStringAsFixed(3)), _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), _TableCell('¥${item.totalPrice.toStringAsFixed(2)}'), - TableCell( - verticalAlignment: TableCellVerticalAlignment.fill, - child: Container( - color: const Color(0xFFEBF3FF), - child: Center( - child: Text( - qty != null ? qty.toStringAsFixed(0) : '-', - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: invColor), - ), - ), - ), - ), ], ); }), @@ -852,3 +892,151 @@ class _StatusFilterDropdown extends StatelessWidget { ); } } + +class _LabelPrintDialog extends StatefulWidget { + final StockOutOrder order; + final ProductRepository productRepo; + final String shopName; + final String shopAddress; + final String shopPhone; + const _LabelPrintDialog({ + required this.order, + required this.productRepo, + this.shopName = '', + this.shopAddress = '', + this.shopPhone = '', + }); + + @override + State<_LabelPrintDialog> createState() => _LabelPrintDialogState(); +} + +class _LabelPrintDialogState extends State<_LabelPrintDialog> { + late final List _selected; + bool _printing = false; + String _status = ''; + + @override + void initState() { + super.initState(); + _selected = List.filled(widget.order.items.length, true); + } + + Future _print() async { + setState(() { _printing = true; _status = '正在打印...'; }); + int done = 0; + for (int i = 0; i < widget.order.items.length; i++) { + if (!_selected[i]) continue; + final item = widget.order.items[i]; + try { + final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId); + await printProductLabel( + qrBytes: qrBytes, + name: item.productName ?? '', + code: item.productCode ?? '', + series: item.productSeries, + spec: item.productSpec, + shopName: widget.shopName, + shopAddress: widget.shopAddress, + shopPhone: widget.shopPhone, + ); + done++; + if (mounted) setState(() => _status = '已打印 $done 张...'); + } catch (e) { + if (mounted) { + setState(() => _status = '第${i + 1}行打印失败:$e'); + } + } + } + if (mounted) { + setState(() { _printing = false; _status = '完成,共打印 $done 张'; }); + } + } + + @override + Widget build(BuildContext context) { + final items = widget.order.items; + return Dialog( + child: Container( + width: 520, + constraints: const BoxConstraints(maxHeight: 520), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: const BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + const Text('打印商品标签', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final item = items[i]; + return CheckboxListTile( + value: _selected[i], + onChanged: _printing + ? null + : (v) => setState(() => _selected[i] = v ?? false), + title: Text( + '${item.productCode ?? ''} ${item.productName ?? ''}', + style: const TextStyle(fontSize: 13), + ), + subtitle: Text( + '${item.productSeries ?? ''} ${item.productSpec ?? ''}', + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + ); + }, + ), + ), + if (_status.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text(_status, + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: (_printing || !_selected.contains(true)) ? null : _print, + icon: const Icon(Icons.print_outlined, size: 16), + label: Text(_printing ? '打印中...' : '打印选中'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/widgets/form_dialog.dart b/client/lib/widgets/form_dialog.dart index 2d13d58..b1cb6f3 100644 --- a/client/lib/widgets/form_dialog.dart +++ b/client/lib/widgets/form_dialog.dart @@ -1,3 +1,4 @@ +import '../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import '../core/theme/app_theme.dart'; diff --git a/client/lib/widgets/multi_select_dropdown.dart b/client/lib/widgets/multi_select_dropdown.dart index 51e0df5..0423a6a 100644 --- a/client/lib/widgets/multi_select_dropdown.dart +++ b/client/lib/widgets/multi_select_dropdown.dart @@ -1,3 +1,4 @@ +import '../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import '../core/theme/app_theme.dart'; @@ -50,7 +51,7 @@ class MultiSelectDropdown extends StatelessWidget { } void _show(BuildContext context) { - showDialog( + showAppDialog( context: context, barrierColor: Colors.black12, builder: (_) => _MultiSelectDialog( @@ -213,7 +214,7 @@ class _FilterableColumnHeaderState extends State { } void _show(BuildContext context) { - showDialog( + showAppDialog( context: context, barrierColor: Colors.black12, builder: (_) => _MultiSelectDialog( @@ -273,7 +274,7 @@ class ColumnToggleButton extends StatelessWidget { } void _show(BuildContext context) { - showDialog( + showAppDialog( context: context, barrierColor: Colors.black12, builder: (_) => _ColumnToggleDialog( diff --git a/client/lib/widgets/page_scaffold.dart b/client/lib/widgets/page_scaffold.dart index 7d84523..1be33e4 100644 --- a/client/lib/widgets/page_scaffold.dart +++ b/client/lib/widgets/page_scaffold.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import '../core/theme/app_theme.dart'; -class PageScaffold extends StatelessWidget { +class PageScaffold extends StatefulWidget { final String title; final List tabs; final List tabViews; final int initialTab; + final ValueChanged? onTabChanged; const PageScaffold({ super.key, @@ -13,36 +14,68 @@ class PageScaffold extends StatelessWidget { required this.tabs, required this.tabViews, this.initialTab = 0, + this.onTabChanged, }); + @override + State createState() => _PageScaffoldState(); +} + +class _PageScaffoldState extends State + with SingleTickerProviderStateMixin { + late TabController _controller; + + @override + void initState() { + super.initState(); + _controller = TabController( + length: widget.tabs.length, + initialIndex: widget.initialTab.clamp(0, widget.tabs.length - 1), + vsync: this, + ); + if (widget.onTabChanged != null) { + _controller.addListener(() { + if (!_controller.indexIsChanging) { + widget.onTabChanged!(_controller.index); + } + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return DefaultTabController( - length: tabs.length, - initialIndex: initialTab, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - color: AppTheme.surface, - child: TabBar( - isScrollable: true, - tabAlignment: TabAlignment.start, - labelColor: AppTheme.primary, - unselectedLabelColor: AppTheme.textSecondary, - indicatorColor: AppTheme.primary, - indicatorWeight: 2, - labelStyle: - const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), - tabs: tabs, - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: AppTheme.surface, + child: TabBar( + controller: _controller, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelColor: AppTheme.primary, + unselectedLabelColor: AppTheme.textSecondary, + indicatorColor: AppTheme.primary, + indicatorWeight: 2, + labelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + tabs: widget.tabs, ), - const Divider(height: 1), - Expanded( - child: TabBarView(children: tabViews), + ), + const Divider(height: 1), + Expanded( + child: TabBarView( + controller: _controller, + children: widget.tabViews, ), - ], - ), + ), + ], ); } } diff --git a/client/lib/widgets/searchable_option_field.dart b/client/lib/widgets/searchable_option_field.dart index 4b5ccc5..1a4b156 100644 --- a/client/lib/widgets/searchable_option_field.dart +++ b/client/lib/widgets/searchable_option_field.dart @@ -1,3 +1,4 @@ +import '../core/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import 'package:lpinyin/lpinyin.dart'; import '../core/theme/app_theme.dart'; diff --git a/client/macos/Podfile.lock b/client/macos/Podfile.lock index e2c8248..56524cf 100644 --- a/client/macos/Podfile.lock +++ b/client/macos/Podfile.lock @@ -4,6 +4,8 @@ PODS: - FlutterMacOS (1.0.0) - package_info_plus (0.0.1): - FlutterMacOS + - printing (1.0.0): + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -14,6 +16,7 @@ DEPENDENCIES: - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - printing (from `Flutter/ephemeral/.symlinks/plugins/printing/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -24,6 +27,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + printing: + :path: Flutter/ephemeral/.symlinks/plugins/printing/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin url_launcher_macos: @@ -33,6 +38,7 @@ SPEC CHECKSUMS: file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + printing: c4cf83c78fd684f9bc318e6aadc18972aa48f617 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd diff --git a/client/pubspec.lock b/client/pubspec.lock index 59497d5..f0fbcda 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -174,6 +174,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -260,10 +265,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" jni: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index ebda75a..1f3e5bf 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -9,11 +9,13 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter flutter_riverpod: ^2.5.1 go_router: ^14.0.0 dio: ^5.4.3+1 shared_preferences: ^2.3.0 - intl: ^0.19.0 + intl: ^0.20.2 package_info_plus: ^8.0.0 url_launcher: ^6.3.0 lpinyin: ^2.0.3 diff --git a/docs/context/project.md b/docs/context/project.md index ad439ed..e099207 100644 --- a/docs/context/project.md +++ b/docs/context/project.md @@ -336,3 +336,4 @@ final warehouseOptions = _records | Schema | backend/schema/schema.sql | 完整数据库建表 SQL | | S001 种子 | backend/seeds/S001.sql | 门店 S001 测试数据(含完整入库/出库/库存历史) | | S002 种子 | backend/seeds/S002.sql | 门店 S002 测试数据(基础数据相同,无库存/单据,模拟新门店) | +| 用户手册 | docs/user-manual.md | 酒行员工操作手册(登录/入库/出库/库存/财务/设置) | diff --git a/docs/user-manual.md b/docs/user-manual.md new file mode 100644 index 0000000..7044bdb --- /dev/null +++ b/docs/user-manual.md @@ -0,0 +1,535 @@ +# 酒库管理系统 — 用户操作手册 + +> 适用版本:v1.x +> 受众:酒行管理员、操作员 +> 最后更新:2026-05-23 + +--- + +## 目录 + +1. [系统概述](#1-系统概述) +2. [登录与退出](#2-登录与退出) +3. [界面说明](#3-界面说明) +4. [角色与权限](#4-角色与权限) +5. [入库管理](#5-入库管理) +6. [出库管理](#6-出库管理) +7. [库存管理](#7-库存管理) +8. [财务管理](#8-财务管理) +9. [往来单位](#9-往来单位) +10. [基础数据(商品管理)](#10-基础数据商品管理) +11. [系统设置](#11-系统设置) +12. [常见问题](#12-常见问题) + +--- + +## 1. 系统概述 + +酒库管理系统是专为酒行门店设计的仓库管理工具,涵盖入库、出库、库存查询、财务往来等日常业务。 + +**核心功能一览** + +| 模块 | 主要用途 | +|------|---------| +| 入库管理 | 新建入库单、提交审核、审批通过后自动更新库存 | +| 出库管理 | 新建出库单、提交审核、审批时自动扣减库存 | +| 库存管理 | 查询实时库存、库存盘点、打印标签 | +| 财务管理 | 查看应付/应收账款、结清账款 | +| 往来单位 | 管理供应商和客户信息 | +| 基础数据 | 维护商品名称、系列、规格字典 | +| 系统设置 | 用户管理、仓库管理、编号规则、数据导入 | + +**支持平台**:Windows、macOS、Web、iOS、Android + +--- + +## 2. 登录与退出 + +### 2.1 登录 + +1. 打开系统,进入登录页面。 +2. 在「门店编号」栏输入酒行的门店代号(如 `S001`)。 +3. 输入「用户名」和「密码」。 +4. 点击「登录」按钮。 + +登录成功后,系统自动跳转到「入库管理」页面。 + +**提示**:系统会记录最近登录过的用户名,点击输入框时可从下拉候选列表中快速选择。 + +### 2.2 退出登录 + +点击顶部右侧用户名旁的下拉箭头,选择「退出登录」。 + +### 2.3 查看当前登录信息 + +点击顶部左侧「酒库管理系统」标题或顶部右侧「门店编号」区域,弹出门店信息面板,显示门店编号、登录账号、姓名、系统版本。 + +--- + +## 3. 界面说明 + +系统主界面由三个区域组成: + +``` +┌──────────────────────────────────────────────────────────┐ +│ 顶部导航栏(标题、门店编号、用户名、退出) │ +├──────────┬───────────────────────────────────────────────┤ +│ │ │ +│ 左侧 │ 主内容区域 │ +│ 侧边栏 │ │ +│ │ │ +├──────────┴───────────────────────────────────────────────┤ +│ 状态栏(门店、用户、登录时间、当前时间、连接状态) │ +└──────────────────────────────────────────────────────────┘ +``` + +**左侧侧边栏导航项** + +| 导航项 | 功能 | +|--------|------| +| 入库管理 | 入库单录入与审核 | +| 出库管理 | 出库单录入与审核 | +| 库存管理 | 库存查询与盘点 | +| 财务管理 | 应付/应收账款 | +| 往来单位 | 供应商与客户 | +| 基础数据 | 商品名称/系列/规格字典 | +| 系统设置 | 用户、仓库、编号规则等配置 | + +点击侧边栏顶部的菜单图标可收起/展开侧边栏,节省屏幕空间。 + +**更新提示**:当有新版本时,内容区顶部会显示黄色提示横幅;如为强制更新,会弹出对话框要求立即更新。 + +**离线提示**:网络断开时,顶部显示红色横幅「网络连接已断开」,状态栏变红,此时展示离线缓存数据。 + +--- + +## 4. 角色与权限 + +系统有四个角色,权限从高到低依次为: + +| 角色 | 说明 | 可执行操作 | +|------|------|------------| +| 超级管理员(superadmin) | 系统最高权限账号 | 全部操作,额外可执行数据清空 | +| 管理员(admin) | 门店管理人员 | 全部操作,含用户管理、酒行信息编辑 | +| 操作员(operator) | 日常录入审核人员 | 新建/编辑/提交/审核单据,查询数据 | +| 只读(readonly) | 仅查看数据 | 只能查看,不能新建、修改、删除任何数据 | + +**权限说明** + +- 只读用户点击任何写操作按钮时,系统将直接返回「无权限」提示。 +- 用户管理(新增/编辑/重置密码)仅管理员可操作。 +- 编辑酒行基本信息仅管理员可操作。 +- 数据清空仅超级管理员可操作。 + +--- + +## 5. 入库管理 + +入库管理页面包含两个标签页:**入库审核**(新建和待审核)和**入库单**(已完成记录)。 + +### 5.1 入库单状态说明 + +``` +新建 → 草稿 → 提交审核 → 待审核 → 审核通过 → 已审批 + ↓ + 审核拒绝 → 已拒绝 +``` + +| 状态 | 含义 | +|------|------| +| 草稿 | 已保存但未提交,可继续编辑或删除 | +| 待审核 | 已提交等待审批,不可再编辑 | +| 已审批 | 审批通过,库存已增加,同时生成应付账款记录 | +| 已拒绝 | 审批不通过,库存不变 | + +### 5.2 新建入库单 + +1. 点击左侧「入库管理」,切换到「入库审核」标签页。 +2. 点击右上角「新建入库审核单」按钮。 +3. 填写入库信息: + - **仓库**(必填):选择货物入库的目标仓库。 + - **供应商**(必填):选择供货的往来单位。 + - **入库日期**(必填):默认为今天,可修改。 + - **备注**(选填):填写本批次入库说明。 +4. 在商品明细区域填写每一行商品: + - **商品名称**(必填):从字典中选择或输入。 + - **系列**(必填):选择商品系列。 + - **规格**(必填):选择商品规格(如 500ml×6)。 + - **生产日期**(必填):点击日历图标选择。 + - **批次号**(选填):填写批次编号,用于后续追踪。 + - **数量**(必填):填写入库数量。 + - **单价**(必填):填写进货单价,系统自动计算金额。 +5. 点击「添加商品」可继续增加商品行,点击行末「删除」可移除该行。 +6. 完成后有两种操作: + - **保存草稿**:保存后可以继续修改。 + - **保存并提交审核**:提交后进入待审核状态,不可再编辑。 + +### 5.3 提交审核 + +草稿状态的入库单,在列表中点击「提交」按钮,确认后单据进入「待审核」状态。 + +### 5.4 审批入库单 + +具有操作员及以上权限的用户均可审批。 + +1. 在「入库审核」标签页中找到待审核的入库单。 +2. 点击「通过」:弹窗确认,确认后系统自动将商品入库,库存增加,同时生成一笔应付账款记录。 +3. 点击「拒绝」:弹窗确认,拒绝后库存不变,单据进入「已拒绝」状态。 + +**注意**:审批通过后操作不可撤销,请核对商品数量和单价后再确认。 + +### 5.5 查看入库单详情 + +点击入库单号(蓝色下划线链接)可弹出详情页,显示完整的商品明细、数量、金额及审核信息。详情弹窗右上角有打印按钮,可直接打印入库单。 + +### 5.6 打印入库单 + +在列表中点击对应行的「打印」按钮,直接调用打印机打印入库单据。 + +### 5.7 打印商品标签 + +1. 在列表中点击对应行的「打标签」按钮。 +2. 弹出商品标签打印对话框,列出本次入库的所有商品。 +3. 勾选需要打印标签的商品(默认全选)。 +4. 点击「打印选中」,系统逐张打印选中商品的标签。 + +标签内容包括:商品名称、编码、系列、规格、批次号、生产日期、酒行名称/地址/电话及二维码。 + +### 5.8 结清账款 + +审批通过的入库单会自动生成应付账款记录。当货款结清后,在列表中点击「结清」按钮,确认后将对应财务记录标记为已结清。 + +### 5.9 筛选与导出 + +- **仓库筛选**:点击列头「仓库」的筛选图标,选择仓库进行过滤。 +- **供应商筛选**:点击列头「供应商」的筛选图标,选择供应商进行过滤。 +- **日期筛选**:点击「选择日期」按钮,选择日期范围。 +- **状态筛选**:在入库单标签页中通过状态下拉框筛选。 +- **导出**:点击「导出」按钮,将当前列表导出为 Excel 文件。 + +--- + +## 6. 出库管理 + +出库管理与入库管理操作流程类似,包含**出库审核**(待处理)和**出库单**(已完成)两个标签页。 + +### 6.1 出库单状态说明 + +出库单与入库单状态流转相同:草稿 → 待审核 → 已审批/已拒绝。 + +审批通过后: +- 系统校验库存是否充足,库存不足时审批失败并提示错误,库存不变。 +- 库存充足时自动扣减库存,并生成应收账款记录。 + +### 6.2 新建出库单 + +1. 点击左侧「出库管理」,切换到「出库审核」标签页。 +2. 点击右上角「新建出库审核单」按钮。 +3. 填写基本信息: + - **仓库**(必填):选择出货仓库。 + - **客户**(必填):选择购买方(往来单位中的客户类型)。 + - **出库日期**(必填):默认今天,可修改。 +4. 填写商品明细:商品名称、系列、规格、数量、单价(操作方式同入库单)。 +5. 保存草稿或保存并提交审核。 + +**注意**:出库数量不能超过当前库存。审核时系统会自动校验,库存不足会提示失败。 + +### 6.3 审批出库单 + +操作步骤与入库审批相同。审批通过后库存自动扣减,同时生成应收账款记录。 + +### 6.4 打印出库单 + +在列表中点击「打印」按钮,打印出库单据。 + +### 6.5 结清账款 + +出库单审批通过后生成应收账款。收到货款后,在列表中点击「结清」按钮,将对应财务记录标记为已结清。 + +--- + +## 7. 库存管理 + +### 7.1 查询库存 + +点击左侧「库存管理」进入库存列表。 + +列表显示每个商品的实时库存数量、所在仓库、单价、金额、生产日期、批次等信息。 + +**搜索**:在搜索框输入商品名称关键字,实时过滤结果。 + +**仓库筛选**:点击列头「仓库」的筛选图标,按仓库查看库存。 + +**导出**:点击「导出」按钮,将当前库存列表导出为 Excel 文件。 + +### 7.2 修改备注 + +在库存列表中,点击「备注」列对应单元格(显示铅笔图标或现有备注内容),弹出编辑框,填写后点击「保存」即可直接修改。 + +### 7.3 打印商品标签 + +在库存列表中点击某商品行的「打印标签」按钮,可单独打印该商品的标签。 + +### 7.4 库存盘点 + +1. 在库存管理页面,切换到「库存盘点」标签页,或通过左侧导航进入 `/inventory/check`。 +2. 选择要盘点的**仓库**,系统自动加载该仓库所有商品的账面库存数量。 +3. 选择盘点类型(全盘)。 +4. 逐行填写**实际盘点数量**(与账面不符时修改)。 +5. 点击「提交盘点」完成。 + +盘点单编号由系统自动生成,格式为 `PD{日期}{仓库编号}`。 + +### 7.5 库存流水 + +库存列表页的「库存流水」标签页(如已开放)可查看每次库存变动记录,包括入库/出库时间、变动数量等。 + +--- + +## 8. 财务管理 + +### 8.1 财务记录概述 + +系统自动在以下情况生成财务记录: +- 入库单审批通过 → 自动生成**应付账款**(欠供应商的货款) +- 出库单审批通过 → 自动生成**应收账款**(客户欠的货款) + +点击左侧「财务管理」进入财务页面,包含三个标签: + +| 标签 | 内容 | +|------|------| +| 全部记录 | 所有财务流水 | +| 应付账款 | 需要向供应商付款的记录 | +| 应收账款 | 客户尚未付款的记录 | + +### 8.2 查看财务记录 + +- 默认显示当前月份的记录。 +- 可通过月份选择器切换查看历史月份。 +- 支持按「类型」和「往来单位」列头筛选。 +- 点击「导出」可导出当月数据。 + +### 8.3 结清账款 + +**方式一**:在入库单/出库单列表中点击「结清」按钮,直接结清该单据对应的账款。 + +**方式二**:在财务管理列表中,找到对应记录,点击「结清」按钮,单笔结清。 + +结清后该记录状态从「未结清」变为「已结清」。 + +### 8.4 财务汇总 + +财务管理页面底部(或汇总标签)显示当期应付/应收总额,便于掌握资金往来概况。 + +--- + +## 9. 往来单位 + +往来单位管理供应商和客户两类数据,在新建入库单/出库单时可从此处维护的名称中选择。 + +### 9.1 查看往来单位 + +点击左侧「往来单位」,通过「供应商」和「客户」标签页分别查看。 + +列表支持搜索(按名称关键字过滤)和导出。 + +### 9.2 新建往来单位 + +1. 在对应标签页点击右上角「新建」按钮。 +2. 填写信息: + - **名称**(必填) + - **联系电话**(选填) + - **地址**(选填) + - **卡号**(选填,如有账户绑定) + - **初始余额**(选填,导入历史数据时使用) + - **备注**(选填) +3. 点击「保存」。 + +### 9.3 编辑往来单位 + +在列表中点击「编辑」按钮,修改信息后保存。 + +### 9.4 删除往来单位 + +在列表中点击「删除」按钮,确认后删除。 + +**注意**:已关联入库单/出库单的往来单位不可删除。 + +--- + +## 10. 基础数据(商品管理) + +点击左侧「基础数据」,进入商品字典管理页面,包含三个标签:**商品名称**、**系列**、**规格**。 + +这些字典数据是新建入库/出库单时选择商品的基础,需要先在此处维护。 + +### 10.1 商品名称管理 + +**商品名称**是酒品的主名称,如「茅台飞天 53°」。 + +1. 在「商品名称」标签页,点击「新建」按钮。 +2. 填写商品名称(必填)、编号(选填)、备注(选填)。 +3. 点击「保存」。 + +支持按名称或编号搜索,支持分页浏览,支持编辑和删除。 + +### 10.2 系列管理 + +**系列**是商品所属的系列或品牌线,如「飞天系列」「王子系列」。 + +操作步骤与商品名称相同。 + +### 10.3 规格管理 + +**规格**用于描述包装规格,如「500ml×6」「1000ml×1」。 + +规格还可以设置「单品数量」(即一箱/件包含的单品数量),新建时填写此字段。 + +### 10.4 商品详情与二维码 + +点击「商品管理」(如有商品列表页)进入商品详情,可查看商品图片、二维码。每个商品都有唯一的二维码,可通过扫码访问商品公开信息页面。 + +### 10.5 商品图片上传 + +在商品详情页,点击「上传图片」可为商品添加图片。 + +--- + +## 11. 系统设置 + +点击左侧「系统设置」,包含七个标签页。 + +### 11.1 酒行信息 + +显示本门店的基本信息:门店编号、门店名称、地址、联系电话、负责人。 + +**编辑**(仅管理员):点击「编辑信息」按钮,修改门店名称、地址、电话、负责人后保存。门店编号不可修改。 + +### 11.2 用户管理(仅管理员) + +**查看用户列表**:显示本门店所有用户的姓名、用户名、角色、启用状态。 + +**新增用户**: +1. 点击「新增用户」按钮。 +2. 填写用户名(登录账号,不可重复)、姓名、手机号、初始密码。 +3. 选择角色(操作员/管理员/只读/超级管理员)。 +4. 点击「保存」。 + +**编辑用户**:点击用户行的「编辑」按钮,可修改姓名、手机号、角色(用户名不可修改)。 + +**重置密码**:点击「重置密码」按钮,输入新密码后确认,下次登录时使用新密码。 + +**启用/停用**:在用户列表中直接拨动状态开关,停用后该用户无法登录。 + +### 11.3 仓库管理 + +**查看仓库列表**:显示所有仓库名称、位置、是否默认仓库。 + +**新建仓库**: +1. 点击「新建」按钮。 +2. 填写仓库名称(必填)、位置(选填)。 +3. 可勾选「设为默认仓库」,新建入库/出库单时将自动选择此仓库。 +4. 点击「保存」。 + +**编辑仓库**:点击「编辑」修改仓库信息。 + +**删除仓库**:点击「删除」,确认后删除。已有库存的仓库不可删除。 + +### 11.4 编号规则 + +系统为每类单据自动生成唯一编号,格式为:`{前缀}{日期}{6位序号}`,例如 `RK202605230000001`。 + +**查看当前规则**:列表显示每种单据类型的前缀、日期格式、当前序号及下一编号示例。 + +**修改规则**: +1. 点击对应规则行的「编辑」按钮。 +2. 可修改「前缀」和「当前序号」。 +3. 点击「保存」,修改后对新建单据生效。 + +**注意**:修改当前序号有风险,请勿将序号调小至已使用的范围,否则可能导致单号重复。 + +### 11.5 系统参数 + +配置系统的基本参数,目前包含: + +**基本设置** +- 系统名称 +- 货币单位(默认:人民币 CNY) +- 日期格式(默认:YYYY-MM-DD) +- 时区(默认:Asia/Shanghai UTC+8) + +**审核设置** +- 入库单需要审核(开关) +- 出库单需要审核(开关) +- 允许超量出库(开关) + +修改后点击「保存设置」生效,点击「重置默认」恢复出厂值。 + +### 11.6 数据导入 + +通过 Excel 文件批量导入历史数据。支持以下数据类型: + +| 数据类型 | Excel 格式说明 | +|---------|--------------| +| 往来单位 | 编号 \| 类型 \| 状态 \| 名称 \| 电话 \| 卡号 \| 初始金额 \| 单位 \| 地址 \| 备注 | +| 商品名称 | 选项编号 \| 选项名称 \| 备注 | +| 商品系列 | 选项编号 \| 选项名称 \| 备注 | +| 商品规格 | 选项编号 \| 选项名称 \| 单品数量 \| 备注 | +| 商品编码 | 商品编码:xxxx | +| 库存 | 商品编号 \| 商品名称 \| 系列 \| 规格 \| 单位 \| 库存数量 \| 单价 \| 金额 \| 生产日期 \| 批次 \| 分类 \| 所在仓库 \| 入库日期 \| 供应商 \| 上次盘点 \| 备注 | + +**操作步骤**: +1. 在对应数据类型行点击「选择文件」,选择 Excel(.xls/.xlsx)文件。 +2. 全部文件选择完成后,点击「全部导入」按钮。 +3. 系统依次处理每个文件,显示「共 N 条,新增 N 条,重复跳过 N 条」的导入结果。 +4. 如有失败,在对应行显示错误信息,请根据提示修正文件后重新导入。 + +**导入规则**:以名称去重,已存在的记录不重复导入,导入过程中请勿切换页面。 + +**数据清空(仅超级管理员)**: +1. 在数据导入页面下方「危险操作」区域,勾选需要清空的数据表(入库单、出库单、库存管理、商品详情、往来单位)。 +2. 点击「清空选中数据」。 +3. 在确认弹窗中输入「确认清空」后,点击确认。 + +**警告:数据清空操作不可撤销,请务必提前备份数据。** + +### 11.7 关于 + +**版本信息**:显示当前系统版本,以及最新版本(若有更新可点击「立即更新」下载)。 + +**授权信息**:显示当前授权类型(试用/月付/年付/买断)、授权状态、到期时间。授权即将到期时(剩余 30 天内)会显示倒计时提醒。点击「续费/升级授权」查看联系方式。 + +**关于我们**:开发商信息、官网、技术支持联系方式。 + +**意见反馈**:点击「反馈 Bug」或「功能建议」,通过邮件向开发团队反馈。 + +--- + +## 12. 常见问题 + +**Q:入库单提交后发现填错了,怎么办?** +A:单据提交(待审核状态)后不可修改。请联系管理员将该单据拒绝,然后重新新建一张正确的入库单。 + +**Q:出库审核时提示「库存不足」,怎么处理?** +A:出库数量超过当前仓库库存时,系统会拒绝审批并提示错误。请检查出库数量,或先确认是否有对应入库单已审批通过、库存已更新。 + +**Q:结清了财务记录但发现结清错了,如何撤销?** +A:目前系统不支持撤销结清操作,请联系管理员或技术支持处理。 + +**Q:如何知道某批商品是从哪个入库单入库的?** +A:在库存管理列表中查看商品的批次号,然后在入库管理中按批次号搜索对应入库单。 + +**Q:导入数据时提示「格式错误」,怎么处理?** +A:请检查 Excel 文件列顺序是否与「系统设置 → 数据导入」页面说明的格式一致,且第一行为数据行(无需表头行)。如问题持续,可将文件发送给技术支持排查。 + +**Q:用户无法登录,怎么处理?** +A:请管理员在「系统设置 → 用户管理」中检查该用户是否处于启用状态,并使用「重置密码」为其设置新密码。 + +**Q:操作时提示「无权限」,是什么原因?** +A:当前账号角色为「只读」,不允许执行写操作。如需执行操作,请联系管理员调整账号角色。 + +**Q:系统显示「网络连接已断开」,数据还可靠吗?** +A:网络断开时,系统展示的是上次加载的缓存数据,可能不是最新状态。请恢复网络连接后刷新页面获取最新数据。 + +**Q:如何联系技术支持?** +A:进入「系统设置 → 关于」,查看联系邮箱和技术支持时间。工作日(周一至周五)9:00 - 18:00 可获得响应。