feat: 完成7项功能任务
后端: - fix(backend): InventoryLog 增加 Product/Warehouse 关联,Logs 接口加 Preload 修复流水记录显示"-" - feat(backend): 新增 /inventory/batches 批次追踪接口 - feat(backend): 新增财务记录、编号规则接口(finance、number_rule handler) - fix(backend): service/stock_test.go 修复 model.Date 类型错误 前端: - feat(client): 入库/出库管理 Tab 调换顺序(审核在前),审核 Tab 加新建按钮,列表 Tab 无按钮 - feat(client): 入库单/出库单增加详情弹窗,显示完整单据信息和商品明细 - feat(client): 完善出库单新建表单(StockOutFormScreen),支持选客户/仓库/商品 - fix(client): StockInItem/StockOutItem.fromJson 修复读取嵌套 product 对象而非平铺字段 - feat(client): 批次追踪页面(BatchTrackingScreen)及侧边栏入口 - feat(client): 财务管理、盘点、编号规则接入真实后端 API - fix(client): 入库/出库审核通过后 invalidate inventoryListProvider 刷新库存 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
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 FinanceHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewFinanceHandler(db *gorm.DB) *FinanceHandler {
|
||||
return &FinanceHandler{db: db}
|
||||
}
|
||||
|
||||
// ListRecords GET /api/v1/finance/records
|
||||
func (h *FinanceHandler) ListRecords(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
var q struct {
|
||||
Type string `form:"type"`
|
||||
Month string `form:"month"` // e.g. "2026-04"
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=50"`
|
||||
}
|
||||
if err := c.ShouldBindQuery(&q); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if q.PageSize <= 0 {
|
||||
q.PageSize = 50
|
||||
}
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
|
||||
base := h.db.Model(&model.FinanceRecord{}).Where("shop_id = ?", shopID)
|
||||
if q.Type != "" {
|
||||
base = base.Where("type = ?", q.Type)
|
||||
}
|
||||
if q.Month != "" {
|
||||
base = base.Where("DATE_FORMAT(record_date, '%Y-%m') = ?", q.Month)
|
||||
}
|
||||
|
||||
var total int64
|
||||
base.Count(&total)
|
||||
|
||||
var records []model.FinanceRecord
|
||||
offset := (q.Page - 1) * q.PageSize
|
||||
base.Preload("Partner").Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": records,
|
||||
"total": total,
|
||||
"page": q.Page,
|
||||
"page_size": q.PageSize,
|
||||
})
|
||||
}
|
||||
@@ -64,11 +64,46 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
|
||||
query.Count(&total)
|
||||
|
||||
var logs []model.InventoryLog
|
||||
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
|
||||
query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// Batches GET /api/v1/inventory/batches — 批次追踪:已审核入库单的明细行
|
||||
func (h *InventoryHandler) Batches(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)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": items, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
// CreateCheck POST /api/v1/inventory/checks
|
||||
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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 NumberRuleHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewNumberRuleHandler(db *gorm.DB) *NumberRuleHandler {
|
||||
return &NumberRuleHandler{db: db}
|
||||
}
|
||||
|
||||
// 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)
|
||||
c.JSON(http.StatusOK, gin.H{"data": rules})
|
||||
}
|
||||
|
||||
// Update PUT /api/v1/number-rules/:id
|
||||
func (h *NumberRuleHandler) Update(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var rule model.NumberRule
|
||||
if err := h.db.Where("id = ? AND shop_id = ?", id, shopID).First(&rule).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Prefix string `json:"prefix"`
|
||||
CurrentNo *int `json:"current_no"`
|
||||
DateFormat string `json:"date_format"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
if req.Prefix != "" {
|
||||
updates["prefix"] = req.Prefix
|
||||
}
|
||||
if req.CurrentNo != nil {
|
||||
updates["current_no"] = *req.CurrentNo
|
||||
}
|
||||
if req.DateFormat != "" {
|
||||
updates["date_format"] = req.DateFormat
|
||||
}
|
||||
|
||||
if err := h.db.Model(&rule).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": rule})
|
||||
}
|
||||
@@ -37,7 +37,8 @@ type StockInItem struct {
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:255" json:"remark"`
|
||||
|
||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Order *StockInOrder `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// -------- 出库单 --------
|
||||
@@ -106,6 +107,9 @@ type InventoryLog struct {
|
||||
RefID uint64 `json:"ref_id"`
|
||||
OperatorID *uint64 `json:"operator_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Warehouse *Warehouse `gorm:"foreignKey:WarehouseID" json:"warehouse,omitempty"`
|
||||
}
|
||||
|
||||
// -------- 库存盘点 --------
|
||||
|
||||
@@ -26,6 +26,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
inventoryH := handler.NewInventoryHandler(db)
|
||||
importH := handler.NewImportHandler(db)
|
||||
userH := handler.NewUserHandler(db)
|
||||
financeH := handler.NewFinanceHandler(db)
|
||||
numberRuleH := handler.NewNumberRuleHandler(db)
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
@@ -102,6 +104,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
inventory.GET("", inventoryH.List)
|
||||
inventory.GET("/logs", inventoryH.Logs)
|
||||
inventory.GET("/batches", inventoryH.Batches)
|
||||
inventory.POST("/checks", inventoryH.CreateCheck)
|
||||
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
||||
}
|
||||
@@ -115,6 +118,19 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
users.PUT("/:id/reset-password", userH.ResetPassword)
|
||||
}
|
||||
|
||||
// 财务
|
||||
finance := api.Group("/finance")
|
||||
{
|
||||
finance.GET("/records", financeH.ListRecords)
|
||||
}
|
||||
|
||||
// 编号规则
|
||||
numberRules := api.Group("/number-rules")
|
||||
{
|
||||
numberRules.GET("", numberRuleH.List)
|
||||
numberRules.PUT("/:id", numberRuleH.Update)
|
||||
}
|
||||
|
||||
// 导入
|
||||
imp := api.Group("/import")
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockInItem{
|
||||
{
|
||||
ShopID: shop.ID,
|
||||
@@ -78,7 +78,7 @@ func TestStockService_ApproveStockIn_NotPending(t *testing.T) {
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "draft", // 非 pending 状态
|
||||
OrderDate: time.Now(),
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
}
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockOutItem{
|
||||
{
|
||||
ShopID: shop.ID,
|
||||
@@ -162,7 +162,7 @@ func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockOutItem{
|
||||
{
|
||||
ShopID: shop.ID,
|
||||
@@ -193,7 +193,7 @@ func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) {
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockOutItem{
|
||||
{
|
||||
ShopID: shop.ID,
|
||||
@@ -265,7 +265,7 @@ func TestStockService_MultipleStockIn_AccumulatesInventory(t *testing.T) {
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockInItem{
|
||||
{ShopID: shop.ID, ProductID: product.ID, Quantity: 10},
|
||||
},
|
||||
@@ -280,7 +280,7 @@ func TestStockService_MultipleStockIn_AccumulatesInventory(t *testing.T) {
|
||||
WarehouseID: warehouse.ID,
|
||||
OperatorID: user.ID,
|
||||
Status: "pending",
|
||||
OrderDate: time.Now(),
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockInItem{
|
||||
{ShopID: shop.ID, ProductID: product.ID, Quantity: 5},
|
||||
},
|
||||
|
||||
+18
-7
@@ -242,12 +242,23 @@ INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, qu
|
||||
(12, 1, 2, 8, 'out', 12, 36, 24, 'stock_out', 2, 1, NOW());
|
||||
|
||||
-- ── 财务记录 ────────────────────────────────────────────────
|
||||
-- 出库单 #1 对应应收款 + 已回款
|
||||
-- 应收账款:出库单 #1 (君悦大酒店) + #2 (外滩华尔道夫)
|
||||
-- 收款:出库单 #1 已回款
|
||||
-- 应付账款:入库单 #1 (茅台 339000) + #2 (五粮液代理 51360) + #3 (郎酒 108480)
|
||||
-- 付款:入库单 #1 已付款
|
||||
INSERT INTO finance_records (id, shop_id, partner_id, type, amount, balance, ref_type, ref_id,
|
||||
operator_id, record_date, remark, created_at, updated_at) VALUES
|
||||
(1, 1, 5, 'receivable', 48960, 48960, 'stock_out', 1, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '君悦大酒店4月供货应收款', NOW(), NOW()),
|
||||
(2, 1, 6, 'receivable', 39360, 39360, 'stock_out', 2, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 2 DAY), '外滩华尔道夫进口酒应收款', NOW(), NOW()),
|
||||
(3, 1, 5, 'receipt', 48960, 0, 'stock_out', 1, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 1 DAY), '君悦大酒店回款,结清', NOW(), NOW());
|
||||
(1, 1, 5, 'receivable', 48960, 48960, 'stock_out', 1, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 6 DAY), '君悦大酒店4月供货应收款', NOW(), NOW()),
|
||||
(2, 1, 6, 'receivable', 39360, 39360, 'stock_out', 2, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 5 DAY), '外滩华尔道夫进口酒应收款', NOW(), NOW()),
|
||||
(3, 1, 5, 'receipt', 48960, 0, 'stock_out', 1, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 2 DAY), '君悦大酒店回款,结清', NOW(), NOW()),
|
||||
(4, 1, 1, 'payable', 339000, 339000, 'stock_in', 1, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 6 DAY), '茅台系列首批入库应付款', NOW(), NOW()),
|
||||
(5, 1, 2, 'payable', 51360, 51360, 'stock_in', 2, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 4 DAY), '浓香型白酒入库应付款', NOW(), NOW()),
|
||||
(6, 1, 4, 'payable', 108480, 108480, 'stock_in', 3, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '进口烈酒专库入库应付款', NOW(), NOW()),
|
||||
(7, 1, 1, 'payment', 100000, 0, 'stock_in', 1, 1,
|
||||
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '预付茅台货款10万,银行转账', NOW(), NOW());
|
||||
|
||||
@@ -6,8 +6,10 @@ import '../../screens/shell/app_shell.dart';
|
||||
import '../../screens/stock_in/stock_in_list_screen.dart';
|
||||
import '../../screens/stock_in/stock_in_form_screen.dart';
|
||||
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';
|
||||
@@ -83,6 +85,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: '/stock-out',
|
||||
pageBuilder: (_, __) => _noTransition(const StockOutListScreen())),
|
||||
GoRoute(
|
||||
path: '/stock-out/new',
|
||||
pageBuilder: (_, __) => _noTransition(const StockOutFormScreen())),
|
||||
GoRoute(
|
||||
path: '/inventory',
|
||||
pageBuilder: (_, __) =>
|
||||
@@ -91,6 +96,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
path: '/inventory/check',
|
||||
pageBuilder: (_, __) =>
|
||||
_noTransition(const InventoryCheckScreen())),
|
||||
GoRoute(
|
||||
path: '/batches',
|
||||
pageBuilder: (_, __) =>
|
||||
_noTransition(const BatchTrackingScreen())),
|
||||
GoRoute(
|
||||
path: '/partners',
|
||||
pageBuilder: (_, __) => _noTransition(const PartnersScreen())),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
class FinanceRecord {
|
||||
final int id;
|
||||
final String type; // receivable | payable | receipt | payment
|
||||
final double amount;
|
||||
final double balance;
|
||||
final String? refType;
|
||||
final int? refId;
|
||||
final String? partnerName;
|
||||
final String? recordDate;
|
||||
final String? remark;
|
||||
|
||||
const FinanceRecord({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.balance,
|
||||
this.refType,
|
||||
this.refId,
|
||||
this.partnerName,
|
||||
this.recordDate,
|
||||
this.remark,
|
||||
});
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'receivable':
|
||||
return '应收账款';
|
||||
case 'payable':
|
||||
return '应付账款';
|
||||
case 'receipt':
|
||||
return '收款';
|
||||
case 'payment':
|
||||
return '付款';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
factory FinanceRecord.fromJson(Map<String, dynamic> json) => FinanceRecord(
|
||||
id: (json['id'] as num).toInt(),
|
||||
type: json['type'] as String,
|
||||
amount: (json['amount'] as num).toDouble(),
|
||||
balance: (json['balance'] as num).toDouble(),
|
||||
refType: json['ref_type'] as String?,
|
||||
refId:
|
||||
json['ref_id'] != null ? (json['ref_id'] as num).toInt() : null,
|
||||
partnerName:
|
||||
(json['partner'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
recordDate: json['record_date'] as String?,
|
||||
remark: json['remark'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,61 @@ class Inventory {
|
||||
}
|
||||
}
|
||||
|
||||
// 批次追踪:已审核入库单的明细行
|
||||
class BatchRecord {
|
||||
final int id;
|
||||
final int productId;
|
||||
final String? productName;
|
||||
final String? productCode;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
final String? batchNo;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
final String? orderNo;
|
||||
final String? orderDate;
|
||||
final String? warehouseName;
|
||||
final String? supplierName;
|
||||
|
||||
const BatchRecord({
|
||||
required this.id,
|
||||
required this.productId,
|
||||
this.productName,
|
||||
this.productCode,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
this.batchNo,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.orderNo,
|
||||
this.orderDate,
|
||||
this.warehouseName,
|
||||
this.supplierName,
|
||||
});
|
||||
|
||||
factory BatchRecord.fromJson(Map<String, dynamic> json) {
|
||||
final product = json['product'] as Map<String, dynamic>?;
|
||||
final order = json['order'] as Map<String, dynamic>?;
|
||||
final warehouse = order?['warehouse'] as Map<String, dynamic>?;
|
||||
final partner = order?['partner'] as Map<String, dynamic>?;
|
||||
return BatchRecord(
|
||||
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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InventoryLog {
|
||||
final int warehouseId;
|
||||
final String? warehouseName;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
class NumberRule {
|
||||
final int id;
|
||||
final String type;
|
||||
final String prefix;
|
||||
final int currentNo;
|
||||
final String dateFormat;
|
||||
|
||||
const NumberRule({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.prefix,
|
||||
required this.currentNo,
|
||||
required this.dateFormat,
|
||||
});
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'stock_in':
|
||||
return '入库单';
|
||||
case 'stock_out':
|
||||
return '出库单';
|
||||
case 'inventory_check':
|
||||
return '盘点单';
|
||||
case 'product':
|
||||
return '商品编码';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
String get exampleNo {
|
||||
final now = DateTime.now();
|
||||
final date = '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
return '$prefix$date${(currentNo + 1).toString().padLeft(3, '0')}';
|
||||
}
|
||||
|
||||
factory NumberRule.fromJson(Map<String, dynamic> json) => NumberRule(
|
||||
id: (json['id'] as num).toInt(),
|
||||
type: json['type'] as String,
|
||||
prefix: json['prefix'] as String? ?? '',
|
||||
currentNo: (json['current_no'] as num).toInt(),
|
||||
dateFormat: json['date_format'] as String? ?? 'YYYYMMDD',
|
||||
);
|
||||
}
|
||||
@@ -33,10 +33,10 @@ class StockInItem {
|
||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||
totalPrice: (json['total_price'] as num).toDouble(),
|
||||
batchNo: json['batch_no'] as String?,
|
||||
productName: json['product_name'] as String?,
|
||||
productCode: json['product_code'] as String?,
|
||||
productSpec: json['product_spec'] as String?,
|
||||
productUnit: json['product_unit'] as String?,
|
||||
productName: (json['product'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
productCode: (json['product'] as Map<String, dynamic>?)?['code'] as String?,
|
||||
productSpec: (json['product'] as Map<String, dynamic>?)?['spec'] as String?,
|
||||
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -29,10 +29,10 @@ class StockOutItem {
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||
totalPrice: (json['total_price'] as num).toDouble(),
|
||||
productName: json['product_name'] as String?,
|
||||
productCode: json['product_code'] as String?,
|
||||
productSpec: json['product_spec'] as String?,
|
||||
productUnit: json['product_unit'] as String?,
|
||||
productName: (json['product'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
productCode: (json['product'] as Map<String, dynamic>?)?['code'] as String?,
|
||||
productSpec: (json['product'] as Map<String, dynamic>?)?['spec'] as String?,
|
||||
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/finance.dart';
|
||||
import '../repositories/finance_repository.dart';
|
||||
|
||||
final financeRepositoryProvider = Provider<FinanceRepository>((ref) {
|
||||
return FinanceRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final financeListProvider =
|
||||
AsyncNotifierProvider<FinanceListNotifier, PageResult<FinanceRecord>>(
|
||||
FinanceListNotifier.new,
|
||||
);
|
||||
|
||||
class FinanceListNotifier extends AsyncNotifier<PageResult<FinanceRecord>> {
|
||||
int _page = 1;
|
||||
String _type = '';
|
||||
String _month = '';
|
||||
|
||||
@override
|
||||
Future<PageResult<FinanceRecord>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return _fetch();
|
||||
}
|
||||
|
||||
Future<PageResult<FinanceRecord>> _fetch() {
|
||||
return ref.read(financeRepositoryProvider).listRecords(
|
||||
type: _type.isEmpty ? null : _type,
|
||||
month: _month.isEmpty ? null : _month,
|
||||
page: _page,
|
||||
);
|
||||
}
|
||||
|
||||
void setType(String type) {
|
||||
_type = type;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setMonth(String month) {
|
||||
_month = month;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setPage(int page) {
|
||||
_page = page;
|
||||
reload();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
_fetch().then(
|
||||
(result) => state = AsyncValue.data(result),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../models/number_rule.dart';
|
||||
import '../repositories/number_rule_repository.dart';
|
||||
|
||||
final numberRuleRepositoryProvider = Provider<NumberRuleRepository>((ref) {
|
||||
return NumberRuleRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final numberRuleListProvider =
|
||||
AsyncNotifierProvider<NumberRuleListNotifier, List<NumberRule>>(
|
||||
NumberRuleListNotifier.new,
|
||||
);
|
||||
|
||||
class NumberRuleListNotifier extends AsyncNotifier<List<NumberRule>> {
|
||||
@override
|
||||
Future<List<NumberRule>> build() {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
return ref.read(numberRuleRepositoryProvider).list();
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(numberRuleRepositoryProvider).list());
|
||||
}
|
||||
|
||||
Future<void> updateRule(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(numberRuleRepositoryProvider).update(id, data);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/stock_in.dart';
|
||||
import '../repositories/stock_in_repository.dart';
|
||||
import 'inventory_provider.dart';
|
||||
|
||||
final stockInRepositoryProvider = Provider<StockInRepository>((ref) {
|
||||
return StockInRepository(ref.watch(apiClientProvider));
|
||||
@@ -74,6 +75,7 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
|
||||
Future<void> approveOrder(int id) async {
|
||||
await ref.read(stockInRepositoryProvider).approve(id);
|
||||
reload();
|
||||
ref.invalidate(inventoryListProvider);
|
||||
}
|
||||
|
||||
Future<void> rejectOrder(int id) async {
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../core/auth/auth_state.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/stock_out.dart';
|
||||
import '../repositories/stock_out_repository.dart';
|
||||
import 'inventory_provider.dart';
|
||||
|
||||
final stockOutRepositoryProvider = Provider<StockOutRepository>((ref) {
|
||||
return StockOutRepository(ref.watch(apiClientProvider));
|
||||
@@ -74,6 +75,7 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
||||
Future<void> approveOrder(int id) async {
|
||||
await ref.read(stockOutRepositoryProvider).approve(id);
|
||||
reload();
|
||||
ref.invalidate(inventoryListProvider);
|
||||
}
|
||||
|
||||
Future<void> rejectOrder(int id) async {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../core/models/page_result.dart';
|
||||
import '../models/finance.dart';
|
||||
|
||||
class FinanceRepository {
|
||||
final ApiClient _client;
|
||||
const FinanceRepository(this._client);
|
||||
|
||||
Future<PageResult<FinanceRecord>> listRecords({
|
||||
String? type,
|
||||
String? month,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (type != null && type.isNotEmpty) 'type': type,
|
||||
if (month != null && month.isNotEmpty) 'month': month,
|
||||
};
|
||||
final resp = await _client.get('/finance/records', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
FinanceRecord.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取财务记录失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,44 @@ class InventoryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createCheck(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.post('/inventory/checks', data: data);
|
||||
return resp.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '提交盘点失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<BatchRecord>> listBatches({
|
||||
int? productId,
|
||||
int? warehouseId,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (productId != null) 'product_id': productId,
|
||||
if (warehouseId != null) 'warehouse_id': warehouseId,
|
||||
};
|
||||
final resp = await _client.get('/inventory/batches', params: params);
|
||||
return PageResult.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
BatchRecord.fromJson,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取批次数据失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageResult<InventoryLog>> listLogs({
|
||||
int? warehouseId,
|
||||
int? productId,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import '../models/number_rule.dart';
|
||||
|
||||
class NumberRuleRepository {
|
||||
final ApiClient _client;
|
||||
const NumberRuleRepository(this._client);
|
||||
|
||||
Future<List<NumberRule>> list() async {
|
||||
try {
|
||||
final resp = await _client.get('/number-rules');
|
||||
final data = resp.data['data'] as List;
|
||||
return data
|
||||
.map((e) => NumberRule.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '获取编号规则失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<NumberRule> update(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
final resp = await _client.put('/number-rules/$id', data: data);
|
||||
return NumberRule.fromJson(resp.data['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '更新编号规则失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/inventory.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
|
||||
class BatchTrackingScreen extends ConsumerStatefulWidget {
|
||||
const BatchTrackingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BatchTrackingScreen> createState() =>
|
||||
_BatchTrackingScreenState();
|
||||
}
|
||||
|
||||
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
||||
int _page = 1;
|
||||
late Future<List<BatchRecord>> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetch();
|
||||
}
|
||||
|
||||
void _fetch() {
|
||||
_future = ref
|
||||
.read(inventoryRepositoryProvider)
|
||||
.listBatches(page: _page, pageSize: 20)
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
void _refetch() {
|
||||
setState(() => _fetch());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<BatchRecord>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _refetch, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return _buildTable(snap.data ?? []);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTable(List<BatchRecord> records) {
|
||||
return DataTableCard(
|
||||
totalCount: records.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) {
|
||||
setState(() {
|
||||
_page = p;
|
||||
_fetch();
|
||||
});
|
||||
},
|
||||
toolbar: Row(
|
||||
children: [
|
||||
const Text('已审核入库批次',
|
||||
style: TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_page = 1;
|
||||
_fetch();
|
||||
});
|
||||
},
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('商品')),
|
||||
DataColumn(label: Text('规格')),
|
||||
DataColumn(label: Text('批次号')),
|
||||
DataColumn(label: Text('入库单号')),
|
||||
DataColumn(label: Text('供应商')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('入库日期')),
|
||||
DataColumn(label: Text('数量'), numeric: true),
|
||||
DataColumn(label: Text('单价'), numeric: true),
|
||||
],
|
||||
rows: records.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(SizedBox()),
|
||||
DataCell(Text('暂无批次记录',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: records.map((r) {
|
||||
final hasBatch =
|
||||
r.batchNo != null && r.batchNo!.isNotEmpty;
|
||||
return DataRow(cells: [
|
||||
DataCell(
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(r.productName ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500)),
|
||||
if (r.productCode != null)
|
||||
Text(r.productCode!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
fontFamily: 'monospace')),
|
||||
],
|
||||
),
|
||||
),
|
||||
DataCell(Text(r.productSpec ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(hasBatch
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(r.batchNo!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace')),
|
||||
)
|
||||
: const Text('-',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(Text(r.orderNo ?? '-',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace'))),
|
||||
DataCell(Text(r.supplierName ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(r.warehouseName ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(
|
||||
r.orderDate?.substring(0, 10) ?? '-',
|
||||
style: const TextStyle(fontSize: 12))),
|
||||
DataCell(Text(
|
||||
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(
|
||||
'¥${r.unitPrice.toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontSize: 13))),
|
||||
]);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import 'package:flutter/services.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 '../../models/warehouse.dart';
|
||||
import '../../providers/inventory_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
|
||||
class InventoryCheckScreen extends ConsumerStatefulWidget {
|
||||
const InventoryCheckScreen({super.key});
|
||||
@@ -12,100 +16,124 @@ class InventoryCheckScreen extends ConsumerStatefulWidget {
|
||||
_InventoryCheckScreenState();
|
||||
}
|
||||
|
||||
class _InventoryCheckScreenState
|
||||
extends ConsumerState<InventoryCheckScreen> {
|
||||
final _checkNoCtrl =
|
||||
TextEditingController(text: 'PD20260404001');
|
||||
String _warehouse = '主仓库';
|
||||
class _InventoryCheckScreenState extends ConsumerState<InventoryCheckScreen> {
|
||||
Warehouse? _selectedWarehouse;
|
||||
String _checkType = '全盘';
|
||||
bool _submitting = false;
|
||||
bool _loadingItems = false;
|
||||
|
||||
// Mock inventory items for checking
|
||||
final List<Map<String, dynamic>> _checkItems = [
|
||||
{
|
||||
'code': 'SP001',
|
||||
'name': '茅台酒(飞天)53度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 286,
|
||||
'actualQtyCtrl': TextEditingController(text: '286'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP002',
|
||||
'name': '五粮液(普五)52度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 152,
|
||||
'actualQtyCtrl': TextEditingController(text: '150'),
|
||||
'remark': TextEditingController(text: '破损2瓶'),
|
||||
},
|
||||
{
|
||||
'code': 'SP003',
|
||||
'name': '洋河梦之蓝M6+ 45度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 88,
|
||||
'actualQtyCtrl': TextEditingController(text: '88'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP005',
|
||||
'name': '泸州老窖(国窖1573)52度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 68,
|
||||
'actualQtyCtrl': TextEditingController(text: '70'),
|
||||
'remark': TextEditingController(text: '盘盈2瓶'),
|
||||
},
|
||||
{
|
||||
'code': 'SP006',
|
||||
'name': '汾酒(青花30)53度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 45,
|
||||
'actualQtyCtrl': TextEditingController(text: '45'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP010',
|
||||
'name': '青岛啤酒(经典)500ml',
|
||||
'unit': '箱',
|
||||
'systemQty': 35,
|
||||
'actualQtyCtrl': TextEditingController(text: '35'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
];
|
||||
// Editable check items built from real inventory
|
||||
final List<_CheckItem> _checkItems = [];
|
||||
|
||||
String get _checkNo {
|
||||
final now = DateTime.now();
|
||||
final date =
|
||||
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
return 'PD$date${_selectedWarehouse?.id.toString().padLeft(3, '0') ?? '001'}';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_checkNoCtrl.dispose();
|
||||
for (final item in _checkItems) {
|
||||
(item['actualQtyCtrl'] as TextEditingController).dispose();
|
||||
(item['remark'] as TextEditingController).dispose();
|
||||
item.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _getDiff(Map<String, dynamic> item) {
|
||||
final actual = int.tryParse(
|
||||
(item['actualQtyCtrl'] as TextEditingController).text) ??
|
||||
0;
|
||||
return actual - (item['systemQty'] as int);
|
||||
Future<void> _loadInventory(Warehouse wh) async {
|
||||
setState(() {
|
||||
_loadingItems = true;
|
||||
for (final item in _checkItems) {
|
||||
item.dispose();
|
||||
}
|
||||
_checkItems.clear();
|
||||
});
|
||||
try {
|
||||
final result = await ref
|
||||
.read(inventoryRepositoryProvider)
|
||||
.listInventory(warehouseId: wh.id, pageSize: 200);
|
||||
final items = result.data
|
||||
.map((inv) => _CheckItem(inventory: inv))
|
||||
.toList();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_checkItems.addAll(items);
|
||||
_loadingItems = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _loadingItems = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('加载库存失败:$e'),
|
||||
backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() => _submitting = true);
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('盘点单已提交,待审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/inventory');
|
||||
if (_selectedWarehouse == null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('请先选择仓库')));
|
||||
return;
|
||||
}
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final dateStr =
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
|
||||
final items = _checkItems.map((item) {
|
||||
final actual =
|
||||
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
|
||||
return {
|
||||
'product_id': item.inventory.productId,
|
||||
'actual_qty': actual,
|
||||
'remark': item.remarkCtrl.text.trim(),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
await ref.read(inventoryRepositoryProvider).createCheck({
|
||||
'check_no': _checkNo,
|
||||
'warehouse_id': _selectedWarehouse!.id,
|
||||
'check_date': dateStr,
|
||||
'items': items,
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('盘点单已提交'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/inventory');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('提交失败:$e'),
|
||||
backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
int _getDiff(_CheckItem item) {
|
||||
final actual =
|
||||
double.tryParse(item.actualQtyCtrl.text) ?? item.inventory.quantity;
|
||||
return (actual - item.inventory.quantity).round();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
@@ -127,12 +155,14 @@ class _InventoryCheckScreenState
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('保存草稿'),
|
||||
onPressed: () => context.go('/inventory'),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
onPressed: (_submitting || _checkItems.isEmpty)
|
||||
? null
|
||||
: _submit,
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
@@ -142,11 +172,6 @@ class _InventoryCheckScreenState
|
||||
: const Icon(Icons.check_circle_outline, size: 16),
|
||||
label: const Text('提交盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go('/inventory'),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -175,28 +200,43 @@ class _InventoryCheckScreenState
|
||||
children: [
|
||||
_InfoField(
|
||||
label: '盘点单号',
|
||||
child: TextFormField(
|
||||
controller: _checkNoCtrl,
|
||||
readOnly: true,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace'),
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(),
|
||||
child: Text(
|
||||
_checkNo,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
label: '盘点仓库',
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _warehouse,
|
||||
items: ['主仓库', '副仓库', '全部仓库']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _warehouse = v!),
|
||||
decoration: const InputDecoration(),
|
||||
child: asyncWarehouses.when(
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (e, _) => Text('$e',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.danger,
|
||||
fontSize: 12)),
|
||||
data: (warehouses) =>
|
||||
DropdownButtonFormField<Warehouse>(
|
||||
value: _selectedWarehouse,
|
||||
hint: const Text('请选择仓库'),
|
||||
items: warehouses
|
||||
.map((w) => DropdownMenuItem(
|
||||
value: w,
|
||||
child: Text(w.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (w) {
|
||||
setState(
|
||||
() => _selectedWarehouse = w);
|
||||
if (w != null) _loadInventory(w);
|
||||
},
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
@@ -219,10 +259,14 @@ class _InventoryCheckScreenState
|
||||
label: '盘点日期',
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(),
|
||||
child: Text(
|
||||
'2026-04-04',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
child: Builder(builder: (ctx) {
|
||||
final now = DateTime.now();
|
||||
return Text(
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}',
|
||||
style:
|
||||
const TextStyle(fontSize: 13),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -247,103 +291,133 @@ class _InventoryCheckScreenState
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accent.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
if (_checkItems.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppTheme.accent.withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length} 项',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.accent),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length} 项',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Table
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FixedColumnWidth(80),
|
||||
2: FlexColumnWidth(3),
|
||||
3: FixedColumnWidth(50),
|
||||
4: FixedColumnWidth(80),
|
||||
5: FixedColumnWidth(120),
|
||||
6: FixedColumnWidth(80),
|
||||
7: FlexColumnWidth(2),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF0F4FF)),
|
||||
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)),
|
||||
))
|
||||
.toList(),
|
||||
if (_selectedWarehouse == null)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text('请先选择盘点仓库',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary)),
|
||||
),
|
||||
...List.generate(
|
||||
_checkItems.length,
|
||||
(i) => _buildCheckRow(i)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Summary
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
)
|
||||
else if (_loadingItems)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_checkItems.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text('该仓库暂无库存记录',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary)),
|
||||
),
|
||||
)
|
||||
else
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FixedColumnWidth(80),
|
||||
2: FlexColumnWidth(3),
|
||||
3: FixedColumnWidth(50),
|
||||
4: FixedColumnWidth(80),
|
||||
5: FixedColumnWidth(120),
|
||||
6: FixedColumnWidth(80),
|
||||
7: FlexColumnWidth(2),
|
||||
},
|
||||
children: [
|
||||
_SummaryItem(
|
||||
label: '盘点商品',
|
||||
value: '${_checkItems.length}种',
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘盈',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) > 0).length}种',
|
||||
color: AppTheme.success,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘亏',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) < 0).length}种',
|
||||
color: AppTheme.danger,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '相符',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) == 0).length}种',
|
||||
color: AppTheme.textSecondary,
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF0F4FF)),
|
||||
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)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...List.generate(
|
||||
_checkItems.length,
|
||||
(i) => _buildCheckRow(i)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_checkItems.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_SummaryItem(
|
||||
label: '盘点商品',
|
||||
value: '${_checkItems.length}种',
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘盈',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) > 0).length}种',
|
||||
color: AppTheme.success,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘亏',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) < 0).length}种',
|
||||
color: AppTheme.danger,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '相符',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) == 0).length}种',
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -360,6 +434,7 @@ class _InventoryCheckScreenState
|
||||
TableRow _buildCheckRow(int index) {
|
||||
final item = _checkItems[index];
|
||||
final diff = _getDiff(item);
|
||||
final inv = item.inventory;
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
@@ -371,39 +446,44 @@ class _InventoryCheckScreenState
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['code'] as String,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(inv.productCode ?? '-',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['name'] as String,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(inv.productName ?? '-',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['unit'] as String,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(inv.productUnit ?? '-',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text('${item['systemQty']}',
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(inv.quantity.toStringAsFixed(0),
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item['actualQtyCtrl'] as TextEditingController,
|
||||
controller: item.actualQtyCtrl,
|
||||
decoration: const InputDecoration(),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: TextInputType.number,
|
||||
@@ -412,7 +492,8 @@ class _InventoryCheckScreenState
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(
|
||||
diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'),
|
||||
style: TextStyle(
|
||||
@@ -427,7 +508,7 @@ class _InventoryCheckScreenState
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item['remark'] as TextEditingController,
|
||||
controller: item.remarkCtrl,
|
||||
decoration: const InputDecoration(hintText: '备注'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
@@ -437,6 +518,22 @@ class _InventoryCheckScreenState
|
||||
}
|
||||
}
|
||||
|
||||
class _CheckItem {
|
||||
final Inventory inventory;
|
||||
final TextEditingController actualQtyCtrl;
|
||||
final TextEditingController remarkCtrl;
|
||||
|
||||
_CheckItem({required this.inventory})
|
||||
: actualQtyCtrl = TextEditingController(
|
||||
text: inventory.quantity.toStringAsFixed(0)),
|
||||
remarkCtrl = TextEditingController();
|
||||
|
||||
void dispose() {
|
||||
actualQtyCtrl.dispose();
|
||||
remarkCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
@@ -467,9 +564,7 @@ class _SummaryItem extends StatelessWidget {
|
||||
final Color color;
|
||||
|
||||
const _SummaryItem(
|
||||
{required this.label,
|
||||
required this.value,
|
||||
required this.color});
|
||||
{required this.label, required this.value, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/number_rule.dart';
|
||||
import '../../models/user.dart';
|
||||
import '../../models/warehouse.dart';
|
||||
import '../../providers/number_rule_provider.dart';
|
||||
import '../../providers/user_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
|
||||
@@ -311,67 +313,149 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
}
|
||||
|
||||
Widget _buildNumberRulesTab() {
|
||||
final rules = [
|
||||
{'type': '入库单', 'prefix': 'RK', 'format': 'RK{年}{月}{日}{序号4}', 'example': 'RK20260404001', 'currentNo': 4},
|
||||
{'type': '出库单', 'prefix': 'CK', 'format': 'CK{年}{月}{日}{序号4}', 'example': 'CK20260404001', 'currentNo': 2},
|
||||
{'type': '盘点单', 'prefix': 'PD', 'format': 'PD{年}{月}{日}{序号4}', 'example': 'PD20260404001', 'currentNo': 1},
|
||||
{'type': '商品编码', 'prefix': 'SP', 'format': 'SP{序号3}', 'example': 'SP001', 'currentNo': 12},
|
||||
];
|
||||
|
||||
return 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: DataTable(
|
||||
headingRowColor:
|
||||
WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||||
columns: const [
|
||||
DataColumn(label: Text('单据类型')),
|
||||
DataColumn(label: Text('前缀')),
|
||||
DataColumn(label: Text('格式')),
|
||||
DataColumn(label: Text('示例')),
|
||||
DataColumn(label: Text('当前序号'), numeric: true),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: rules
|
||||
.map((r) => DataRow(cells: [
|
||||
DataCell(Text(r['type'] as String,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(r['prefix'] as String,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600)),
|
||||
)),
|
||||
DataCell(Text(r['format'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary))),
|
||||
DataCell(Text(r['example'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text('${r['currentNo']}')),
|
||||
DataCell(TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 12)))),
|
||||
]))
|
||||
.toList(),
|
||||
final asyncRules = ref.watch(numberRuleListProvider);
|
||||
return asyncRules.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('加载失败:$e',
|
||||
style: const TextStyle(color: AppTheme.danger)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(numberRuleListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (rules) => 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: DataTable(
|
||||
headingRowColor:
|
||||
WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||||
columns: const [
|
||||
DataColumn(label: Text('单据类型')),
|
||||
DataColumn(label: Text('前缀')),
|
||||
DataColumn(label: Text('日期格式')),
|
||||
DataColumn(label: Text('下一编号示例')),
|
||||
DataColumn(label: Text('当前序号'), numeric: true),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: rules
|
||||
.map((r) => DataRow(cells: [
|
||||
DataCell(Text(r.typeLabel,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500))),
|
||||
DataCell(Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(r.prefix,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600)),
|
||||
)),
|
||||
DataCell(Text(r.dateFormat,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(Text(r.exampleNo,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(Text('${r.currentNo}')),
|
||||
DataCell(TextButton(
|
||||
onPressed: () =>
|
||||
_showNumberRuleDialog(context, r),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)))),
|
||||
]))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showNumberRuleDialog(BuildContext context, NumberRule rule) {
|
||||
final prefixCtrl = TextEditingController(text: rule.prefix);
|
||||
final currentNoCtrl =
|
||||
TextEditingController(text: '${rule.currentNo}');
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text('编辑编号规则 — ${rule.typeLabel}'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: prefixCtrl,
|
||||
decoration: const InputDecoration(labelText: '前缀'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: currentNoCtrl,
|
||||
decoration: const InputDecoration(labelText: '当前序号'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(ctx).pop();
|
||||
try {
|
||||
await ref
|
||||
.read(numberRuleListProvider.notifier)
|
||||
.updateRule(rule.id, {
|
||||
'prefix': prefixCtrl.text.trim(),
|
||||
'current_no': int.tryParse(currentNoCtrl.text) ?? rule.currentNo,
|
||||
});
|
||||
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'),
|
||||
backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
_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: '财务管理',
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../providers/stock_in_provider.dart';
|
||||
import '../../repositories/stock_in_repository.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
@@ -45,17 +46,17 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
return PageScaffold(
|
||||
title: '入库管理',
|
||||
tabs: const [
|
||||
Tab(text: '入库单'),
|
||||
Tab(text: '入库审核'),
|
||||
Tab(text: '入库单'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildListTab(filterStatus: null),
|
||||
_buildListTab(filterStatus: 'pending'),
|
||||
_buildListTab(filterStatus: 'pending', showNewButton: true),
|
||||
_buildListTab(filterStatus: null, showNewButton: false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListTab({String? filterStatus}) {
|
||||
Widget _buildListTab({String? filterStatus, required bool showNewButton}) {
|
||||
final asyncOrders = ref.watch(stockInListProvider);
|
||||
return asyncOrders.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -75,7 +76,6 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
),
|
||||
),
|
||||
data: (result) {
|
||||
// Client-side filter for review tab
|
||||
final orders = filterStatus != null
|
||||
? result.data
|
||||
.where((o) => o.status == filterStatus)
|
||||
@@ -86,6 +86,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
totalCount: filterStatus != null ? orders.length : result.total,
|
||||
page: result.page,
|
||||
showStatusFilter: filterStatus == null,
|
||||
showNewButton: showNewButton,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -96,6 +97,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
required int totalCount,
|
||||
required int page,
|
||||
required bool showStatusFilter,
|
||||
required bool showNewButton,
|
||||
}) {
|
||||
return DataTableCard(
|
||||
totalCount: totalCount,
|
||||
@@ -104,11 +106,12 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
ref.read(stockInListProvider.notifier).setPage(p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建入库单'),
|
||||
),
|
||||
if (showNewButton)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建入库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
@@ -187,6 +190,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'draft')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
@@ -224,6 +234,16 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDetail(BuildContext context, int orderId) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _StockInDetailDialog(
|
||||
orderId: orderId,
|
||||
repository: ref.read(stockInRepositoryProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
@@ -354,6 +374,218 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Detail dialog — fetches full order with items
|
||||
class _StockInDetailDialog extends StatefulWidget {
|
||||
final int orderId;
|
||||
final StockInRepository repository;
|
||||
|
||||
const _StockInDetailDialog({
|
||||
required this.orderId,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_StockInDetailDialog> createState() => _StockInDetailDialogState();
|
||||
}
|
||||
|
||||
class _StockInDetailDialogState extends State<_StockInDetailDialog> {
|
||||
late Future<StockInOrder> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.get(widget.orderId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 720,
|
||||
constraints: const BoxConstraints(maxHeight: 600),
|
||||
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: FutureBuilder<StockInOrder>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)));
|
||||
}
|
||||
return _buildContent(snap.data!);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(StockInOrder o) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Basic info
|
||||
Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_InfoField('入库单号', o.orderNo),
|
||||
_InfoField('状态', _statusLabel(o.status)),
|
||||
_InfoField('供应商', o.partnerName ?? '-'),
|
||||
_InfoField('仓库', o.warehouseName ?? '-'),
|
||||
_InfoField('入库日期', o.orderDate?.substring(0, 10) ?? '-'),
|
||||
_InfoField('合计金额',
|
||||
o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'),
|
||||
if (o.remark != null && o.remark!.isNotEmpty)
|
||||
_InfoField('备注', o.remark!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text('商品明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(height: 8),
|
||||
if (o.items.isEmpty)
|
||||
const Text('无商品明细',
|
||||
style: TextStyle(color: AppTheme.textSecondary))
|
||||
else
|
||||
Table(
|
||||
border: TableBorder.all(color: AppTheme.border, width: 0.5),
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(3),
|
||||
2: FlexColumnWidth(1.5),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.5),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '商品', '规格', '数量', '单价', '金额']
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
child: Text(h,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...o.items.asMap().entries.map((e) {
|
||||
final i = e.key;
|
||||
final item = e.value;
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)),
|
||||
children: [
|
||||
_TableCell('${i + 1}'),
|
||||
_TableCell(item.productName ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _statusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '草稿';
|
||||
case 'pending':
|
||||
return '待审核';
|
||||
case 'approved':
|
||||
return '已审核';
|
||||
case 'rejected':
|
||||
return '已拒绝';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoField extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoField(this.label, this.value);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TableCell extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _TableCell(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(text, style: const TextStyle(fontSize: 13)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusFilterDropdown extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String?> onChanged;
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../providers/partner_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/stock_out_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
|
||||
class StockOutFormScreen extends ConsumerStatefulWidget {
|
||||
const StockOutFormScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StockOutFormScreen> createState() => _StockOutFormScreenState();
|
||||
}
|
||||
|
||||
class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _remarkCtrl = TextEditingController();
|
||||
int? _warehouseId;
|
||||
int? _partnerId;
|
||||
DateTime _orderDate = DateTime.now();
|
||||
bool _submitting = false;
|
||||
|
||||
final List<_ItemRow> _items = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items.add(_ItemRow());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remarkCtrl.dispose();
|
||||
for (final item in _items) {
|
||||
item.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _totalAmount {
|
||||
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;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void _addItem() {
|
||||
setState(() => _items.add(_ItemRow()));
|
||||
}
|
||||
|
||||
void _removeItem(int index) {
|
||||
setState(() {
|
||||
_items[index].dispose();
|
||||
_items.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit(bool asDraft) async {
|
||||
if (!asDraft && !_formKey.currentState!.validate()) return;
|
||||
if (_warehouseId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请选择出库仓库'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
|
||||
final itemsData = _items.map((item) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
return {
|
||||
'product_id': item.productId ?? 0,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'total_price': qty * price,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final data = {
|
||||
'warehouse_id': _warehouseId,
|
||||
if (_partnerId != null) 'partner_id': _partnerId,
|
||||
'order_date':
|
||||
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
|
||||
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
|
||||
'items': itemsData,
|
||||
'status': asDraft ? 'draft' : 'pending',
|
||||
};
|
||||
|
||||
try {
|
||||
await ref.read(stockOutListProvider.notifier).createOrder(data);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(asDraft ? '已保存为草稿' : '出库单已提交审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/stock-out');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||
final asyncCustomers = ref.watch(customerListProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () => context.go('/stock-out'),
|
||||
tooltip: '返回',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('新建出库单',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : () => _submit(true),
|
||||
child: const Text('保存草稿'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _submitting ? null : () => _submit(false),
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.send, size: 16),
|
||||
label: const Text('提交审核'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/stock-out'),
|
||||
icon: const Icon(Icons.cancel_outlined, size: 16),
|
||||
label: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('基本信息',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
// Warehouse dropdown
|
||||
_FormField(
|
||||
label: '出库仓库',
|
||||
required: true,
|
||||
child: asyncWarehouses.when(
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (warehouses) =>
|
||||
DropdownButtonFormField<int>(
|
||||
value: _warehouseId,
|
||||
hint: const Text('请选择仓库',
|
||||
style:
|
||||
TextStyle(fontSize: 13)),
|
||||
items: warehouses
|
||||
.map((w) => DropdownMenuItem(
|
||||
value: w.id,
|
||||
child: Text(w.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(
|
||||
() => _warehouseId = v),
|
||||
validator: (v) =>
|
||||
v == null ? '不能为空' : null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Customer dropdown
|
||||
_FormField(
|
||||
label: '客户',
|
||||
child: asyncCustomers.when(
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (result) =>
|
||||
DropdownButtonFormField<int>(
|
||||
value: _partnerId,
|
||||
hint: const Text('请选择客户',
|
||||
style:
|
||||
TextStyle(fontSize: 13)),
|
||||
items: result.data
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(p.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(
|
||||
() => _partnerId = v),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Date picker
|
||||
_FormField(
|
||||
label: '出库日期',
|
||||
required: true,
|
||||
child: InkWell(
|
||||
onTap: _pickDate,
|
||||
child: InputDecorator(
|
||||
decoration:
|
||||
const InputDecoration(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color:
|
||||
AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '备注',
|
||||
width: double.infinity,
|
||||
child: TextFormField(
|
||||
controller: _remarkCtrl,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '选填,如有特殊说明请在此注明',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('商品明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _addItem,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('添加商品'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 32)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(3),
|
||||
2: FlexColumnWidth(1.5),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FixedColumnWidth(60),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF0F4FF)),
|
||||
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)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...List.generate(
|
||||
_items.length,
|
||||
(i) => _buildItemRow(i)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text('合计金额:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500)),
|
||||
Text(
|
||||
'¥${_totalAmount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TableRow _buildItemRow(int index) {
|
||||
final item = _items[index];
|
||||
final asyncProducts = ref.watch(productListProvider);
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
final amount = qty * price;
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: index.isEven ? Colors.white : const Color(0xFFFAFAFA),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: asyncProducts.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (result) => DropdownButtonFormField<int>(
|
||||
value: item.productId,
|
||||
hint: const Text('选择商品',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
items: result.data
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text('${p.name}(${p.spec ?? p.unit})',
|
||||
style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
item.productId = v;
|
||||
// Auto-fill sale price if available
|
||||
if (v != null) {
|
||||
final found = result.data.firstWhere(
|
||||
(p) => p.id == v,
|
||||
orElse: () => Product(
|
||||
id: 0, code: '', name: '', unit: ''));
|
||||
if (found.salePrice != null) {
|
||||
item.priceCtrl.text =
|
||||
found.salePrice!.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (v) => v == null ? '不能为空' : null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item.qtyCtrl,
|
||||
decoration: const InputDecoration(hintText: '0'),
|
||||
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: '¥'),
|
||||
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.symmetric(horizontal: 8, vertical: 8),
|
||||
child: 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,
|
||||
tooltip: '删除',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _orderDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (date != null) setState(() => _orderDate = date);
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemRow {
|
||||
int? productId;
|
||||
final TextEditingController qtyCtrl = TextEditingController();
|
||||
final TextEditingController priceCtrl = TextEditingController();
|
||||
|
||||
void dispose() {
|
||||
qtyCtrl.dispose();
|
||||
priceCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FormField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
final double width;
|
||||
|
||||
const _FormField({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
this.width = 240,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (required)
|
||||
const Text('*',
|
||||
style:
|
||||
TextStyle(color: AppTheme.danger, fontSize: 13)),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
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/stock_out.dart';
|
||||
import '../../providers/stock_out_provider.dart';
|
||||
import '../../repositories/stock_out_repository.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
@@ -45,17 +47,17 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
return PageScaffold(
|
||||
title: '出库管理',
|
||||
tabs: const [
|
||||
Tab(text: '出库单'),
|
||||
Tab(text: '出库审核'),
|
||||
Tab(text: '出库单'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildListTab(filterStatus: null),
|
||||
_buildListTab(filterStatus: 'pending'),
|
||||
_buildListTab(filterStatus: 'pending', showNewButton: true),
|
||||
_buildListTab(filterStatus: null, showNewButton: false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListTab({String? filterStatus}) {
|
||||
Widget _buildListTab({String? filterStatus, required bool showNewButton}) {
|
||||
final asyncOrders = ref.watch(stockOutListProvider);
|
||||
return asyncOrders.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -85,6 +87,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
totalCount: filterStatus != null ? orders.length : result.total,
|
||||
page: result.page,
|
||||
showStatusFilter: filterStatus == null,
|
||||
showNewButton: showNewButton,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -95,6 +98,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
required int totalCount,
|
||||
required int page,
|
||||
required bool showStatusFilter,
|
||||
required bool showNewButton,
|
||||
}) {
|
||||
return DataTableCard(
|
||||
totalCount: totalCount,
|
||||
@@ -103,11 +107,12 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
ref.read(stockOutListProvider.notifier).setPage(p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showCreateDialog(context),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库单'),
|
||||
),
|
||||
if (showNewButton)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
@@ -186,6 +191,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'draft')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
@@ -223,6 +235,16 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDetail(BuildContext context, int orderId) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _StockOutDetailDialog(
|
||||
orderId: orderId,
|
||||
repository: ref.read(stockOutRepositoryProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
@@ -238,24 +260,6 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新建出库单'),
|
||||
content: const SizedBox(
|
||||
width: 400,
|
||||
child: Text('出库单完整创建功能请参考入库单流程。'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmSubmit(
|
||||
BuildContext context, StockOutOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -324,7 +328,6 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
content: Text('审核通过'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
// Show inventory-shortage error clearly
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('审核失败:$e'),
|
||||
@@ -376,6 +379,217 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Detail dialog — fetches full order with items
|
||||
class _StockOutDetailDialog extends StatefulWidget {
|
||||
final int orderId;
|
||||
final StockOutRepository repository;
|
||||
|
||||
const _StockOutDetailDialog({
|
||||
required this.orderId,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_StockOutDetailDialog> createState() => _StockOutDetailDialogState();
|
||||
}
|
||||
|
||||
class _StockOutDetailDialogState extends State<_StockOutDetailDialog> {
|
||||
late Future<StockOutOrder> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.get(widget.orderId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 720,
|
||||
constraints: const BoxConstraints(maxHeight: 600),
|
||||
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: FutureBuilder<StockOutOrder>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)));
|
||||
}
|
||||
return _buildContent(snap.data!);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(StockOutOrder o) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 32,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_InfoField('出库单号', o.orderNo),
|
||||
_InfoField('状态', _statusLabel(o.status)),
|
||||
_InfoField('客户/往来单位', o.partnerName ?? '-'),
|
||||
_InfoField('仓库', o.warehouseName ?? '-'),
|
||||
_InfoField('出库日期', o.orderDate?.substring(0, 10) ?? '-'),
|
||||
_InfoField('合计金额',
|
||||
o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'),
|
||||
if (o.remark != null && o.remark!.isNotEmpty)
|
||||
_InfoField('备注', o.remark!),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text('商品明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(height: 8),
|
||||
if (o.items.isEmpty)
|
||||
const Text('无商品明细',
|
||||
style: TextStyle(color: AppTheme.textSecondary))
|
||||
else
|
||||
Table(
|
||||
border: TableBorder.all(color: AppTheme.border, width: 0.5),
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(3),
|
||||
2: FlexColumnWidth(1.5),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.5),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '商品', '规格', '数量', '单价', '金额']
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
child: Text(h,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...o.items.asMap().entries.map((e) {
|
||||
final i = e.key;
|
||||
final item = e.value;
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)),
|
||||
children: [
|
||||
_TableCell('${i + 1}'),
|
||||
_TableCell(item.productName ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _statusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return '草稿';
|
||||
case 'pending':
|
||||
return '待审核';
|
||||
case 'approved':
|
||||
return '已审核';
|
||||
case 'rejected':
|
||||
return '已拒绝';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoField extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoField(this.label, this.value);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TableCell extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _TableCell(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(text, style: const TextStyle(fontSize: 13)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusFilterDropdown extends StatelessWidget {
|
||||
final String value;
|
||||
final ValueChanged<String?> onChanged;
|
||||
|
||||
Reference in New Issue
Block a user