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:
wangjia
2026-04-10 22:03:19 +08:00
parent 5f8e7cfa7e
commit ce1cbf404c
28 changed files with 2507 additions and 1072 deletions
+63
View File
@@ -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,
})
}
+36 -1
View File
@@ -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)
+66
View File
@@ -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})
}
+5 -1
View File
@@ -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"`
}
// -------- 库存盘点 --------
+16
View File
@@ -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")
{
+7 -7
View File
@@ -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
View File
@@ -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());
+9
View File
@@ -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())),
+52
View File
@@ -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?,
);
}
+55
View File
@@ -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;
+44
View File
@@ -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',
);
}
+4 -4
View File
@@ -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() => {
+4 -4
View File
@@ -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': '泸州老窖(国窖157352度500ml',
'unit': '',
'systemQty': 68,
'actualQtyCtrl': TextEditingController(text: '70'),
'remark': TextEditingController(text: '盘盈2瓶'),
},
{
'code': 'SP006',
'name': '汾酒(青花3053度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) {
+144 -60
View File
@@ -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('保存'),
),
],
),
+1
View File
@@ -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;