Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a20645671b | |||
| 6570dfc240 | |||
| 078fab30f0 |
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.75] - 2026-06-22
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 出库单列表新增「商品编码」搜索框:输入完整商品编码回车,即可精确查出包含该商品的所有出库单(与原「单号/往来单位」搜索互不影响)
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
- 移除「系统设置 → 数据管理」里的数据导入入口:数据导入不再对用户开放
|
||||||
|
|
||||||
|
## [1.0.74] - 2026-06-22
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 入库单 / 出库单详情页的商品明细表新增「生产日期」「批次号」两列,便于核对货品批次信息
|
||||||
|
|
||||||
## [1.0.73] - 2026-06-22
|
## [1.0.73] - 2026-06-22
|
||||||
|
|
||||||
### 改进
|
### 改进
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.77] - 2026-06-22
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 出库单列表支持按「商品编码」精确反查:可查出包含某个商品编码的所有出库单。同时为出库明细新增 (门店, 商品编码) 复合索引,在数万条明细下仍保持毫秒级查询,不影响写入。
|
||||||
|
|
||||||
## [1.0.76] - 2026-06-22
|
## [1.0.76] - 2026-06-22
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
|||||||
like, shopID, like,
|
like, shopID, like,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引
|
||||||
|
if code := strings.TrimSpace(c.Query("product_code")); code != "" {
|
||||||
|
query = query.Where(
|
||||||
|
"id IN (SELECT order_id FROM stock_out_items WHERE shop_id = ? AND product_code = ? AND deleted_at IS NULL)",
|
||||||
|
shopID, code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|||||||
@@ -133,6 +133,40 @@ func TestStockOutHandler_List(t *testing.T) {
|
|||||||
assert.Equal(t, float64(2), resp["total"].(float64))
|
assert.Equal(t, float64(2), resp["total"].(float64))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按商品编码精确反查出库单(明细 product_code = ?)
|
||||||
|
func TestStockOutHandler_List_FilterByProductCode(t *testing.T) {
|
||||||
|
db := testutil.SetupTestDB()
|
||||||
|
shop := testutil.CreateTestShop(db, "SO_CODE")
|
||||||
|
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||||
|
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
|
||||||
|
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
|
||||||
|
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||||
|
r := setupProtectedRouter(db)
|
||||||
|
|
||||||
|
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||||
|
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{"product_id": product.ID, "product_code": "ZXZ-FIND", "quantity": 1.0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||||
|
"warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{"product_id": product.ID, "product_code": "OTHER-CODE", "quantity": 1.0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 精确命中 1 单
|
||||||
|
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ-FIND", token, nil)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
|
||||||
|
|
||||||
|
// 不存在的编码 → 0;前缀不算精确(不命中)
|
||||||
|
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?product_code=ZXZ", token, nil)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
|
func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
|
||||||
db := testutil.SetupTestDB()
|
db := testutil.SetupTestDB()
|
||||||
shop := testutil.CreateTestShop(db, "SO011")
|
shop := testutil.CreateTestShop(db, "SO011")
|
||||||
|
|||||||
@@ -144,5 +144,12 @@ func autoMigrate(db *gorm.DB) {
|
|||||||
log.Fatalf("create unique index uk_shop_code failed: %v", err)
|
log.Fatalf("create unique index uk_shop_code failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// stock_out_items(shop_id, product_code) 复合索引:支撑出库列表「按商品编码反查单据」,
|
||||||
|
// 避免在 2.6 万+ 明细行上全表扫。ShopID 在嵌入字段上无法用 struct tag 表达,故显式幂等建。
|
||||||
|
if !db.Migrator().HasIndex(&model.StockOutItem{}, "idx_so_items_shop_code") {
|
||||||
|
if err := db.Exec("CREATE INDEX idx_so_items_shop_code ON stock_out_items (shop_id, product_code)").Error; err != nil {
|
||||||
|
log.Fatalf("create index idx_so_items_shop_code failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
log.Println("AutoMigrate completed")
|
log.Println("AutoMigrate completed")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,7 +369,8 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
|
|||||||
`remark` VARCHAR(255) DEFAULT NULL,
|
`remark` VARCHAR(255) DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_order_id` (`order_id`),
|
KEY `idx_order_id` (`order_id`),
|
||||||
KEY `idx_shop_product` (`shop_id`, `product_id`)
|
KEY `idx_shop_product` (`shop_id`, `product_id`),
|
||||||
|
KEY `idx_so_items_shop_code` (`shop_id`, `product_code`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单明细';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库单明细';
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class StockOutItem {
|
|||||||
final String? productSeries;
|
final String? productSeries;
|
||||||
final String? productSpec;
|
final String? productSpec;
|
||||||
final String? productUnit;
|
final String? productUnit;
|
||||||
|
final String? batchNo;
|
||||||
|
final String? productionDate;
|
||||||
|
|
||||||
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
|
bool get isReturned => returnedQuantity >= quantity && quantity > 0;
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ class StockOutItem {
|
|||||||
this.productSeries,
|
this.productSeries,
|
||||||
this.productSpec,
|
this.productSpec,
|
||||||
this.productUnit,
|
this.productUnit,
|
||||||
|
this.batchNo,
|
||||||
|
this.productionDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory StockOutItem.fromJson(Map<String, dynamic> json) {
|
factory StockOutItem.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -57,6 +61,8 @@ class StockOutItem {
|
|||||||
productSeries: lineOrProduct('series', 'series'),
|
productSeries: lineOrProduct('series', 'series'),
|
||||||
productSpec: lineOrProduct('spec', 'spec'),
|
productSpec: lineOrProduct('spec', 'spec'),
|
||||||
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
||||||
|
batchNo: json['batch_no'] as String?,
|
||||||
|
productionDate: json['production_date'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
|||||||
String? _startDate;
|
String? _startDate;
|
||||||
String? _endDate;
|
String? _endDate;
|
||||||
String _keyword = '';
|
String _keyword = '';
|
||||||
|
String _productCode = '';
|
||||||
PageResult<StockOutOrder>? _cache;
|
PageResult<StockOutOrder>? _cache;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,6 +47,7 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
|||||||
startDate: _startDate,
|
startDate: _startDate,
|
||||||
endDate: _endDate,
|
endDate: _endDate,
|
||||||
keyword: _keyword.isEmpty ? null : _keyword,
|
keyword: _keyword.isEmpty ? null : _keyword,
|
||||||
|
productCode: _productCode.isEmpty ? null : _productCode,
|
||||||
page: _page,
|
page: _page,
|
||||||
pageSize: _pageSize,
|
pageSize: _pageSize,
|
||||||
);
|
);
|
||||||
@@ -84,6 +86,13 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
|
|||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按商品编码精确反查(独立于 keyword)
|
||||||
|
void setProductCode(String code) {
|
||||||
|
_productCode = code;
|
||||||
|
_page = 1;
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
void reload() {
|
void reload() {
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
_fetch().then((result) {
|
_fetch().then((result) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class StockOutRepository {
|
|||||||
String? startDate,
|
String? startDate,
|
||||||
String? endDate,
|
String? endDate,
|
||||||
String? keyword,
|
String? keyword,
|
||||||
|
String? productCode,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -25,6 +26,8 @@ class StockOutRepository {
|
|||||||
if (startDate != null) 'start_date': startDate,
|
if (startDate != null) 'start_date': startDate,
|
||||||
if (endDate != null) 'end_date': endDate,
|
if (endDate != null) 'end_date': endDate,
|
||||||
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
|
if (keyword != null && keyword.isNotEmpty) 'keyword': keyword,
|
||||||
|
if (productCode != null && productCode.isNotEmpty)
|
||||||
|
'product_code': productCode,
|
||||||
};
|
};
|
||||||
final resp = await _client.get('/stock-out/orders', params: params);
|
final resp = await _client.get('/stock-out/orders', params: params);
|
||||||
return PageResult.fromJson(
|
return PageResult.fromJson(
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../../core/utils/dialog_util.dart';
|
import '../../core/utils/dialog_util.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/auth/auth_state.dart';
|
import '../../core/auth/auth_state.dart';
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/config/app_constants.dart';
|
|
||||||
import '../../core/config/app_info.dart';
|
import '../../core/config/app_info.dart';
|
||||||
import '../../core/config/license_copy.dart';
|
import '../../core/config/license_copy.dart';
|
||||||
import '../../core/responsive/responsive.dart';
|
import '../../core/responsive/responsive.dart';
|
||||||
@@ -46,8 +43,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 6,
|
length: 5,
|
||||||
initialIndex: widget.initialTab,
|
initialIndex: widget.initialTab >= 5 ? 0 : widget.initialTab,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -66,7 +63,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
Tab(text: '编号规则'),
|
Tab(text: '编号规则'),
|
||||||
Tab(text: '系统参数'),
|
Tab(text: '系统参数'),
|
||||||
Tab(text: '授权'),
|
Tab(text: '授权'),
|
||||||
Tab(text: '数据管理'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -79,7 +75,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
_buildNumberRulesTab(),
|
_buildNumberRulesTab(),
|
||||||
_buildSystemParamsTab(),
|
_buildSystemParamsTab(),
|
||||||
_buildLicenseTab(),
|
_buildLicenseTab(),
|
||||||
_buildImportTab(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -823,31 +818,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 数据导入 Tab ──────────────────────────────────────────
|
|
||||||
Widget _buildImportTab() {
|
|
||||||
if (WriteGuard.isReadonly(ref)) {
|
|
||||||
return const Center(
|
|
||||||
child: Text('只读账号无导入权限',
|
|
||||||
style: TextStyle(color: AppTheme.textSecondary)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (WriteGuard.licenseBlocked(ref)) {
|
|
||||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Text(
|
|
||||||
lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final currentUser = ref.watch(authStateProvider).user;
|
|
||||||
final isSuperAdmin = currentUser?.role == 'superadmin';
|
|
||||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showEditParamDialog(
|
void _showEditParamDialog(
|
||||||
String label, String current, ValueChanged<String> onSave) {
|
String label, String current, ValueChanged<String> onSave) {
|
||||||
@@ -1213,685 +1183,6 @@ class _ParamRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 批量数据导入 ───────────────────────────────────────────
|
|
||||||
|
|
||||||
class _ImportSlot {
|
|
||||||
final String title;
|
|
||||||
final String endpoint;
|
|
||||||
final String hint;
|
|
||||||
PlatformFile? file;
|
|
||||||
// null = 未运行;true = 成功;false = 失败
|
|
||||||
bool? success;
|
|
||||||
String? error;
|
|
||||||
int total = 0;
|
|
||||||
int imported = 0;
|
|
||||||
int skipped = 0;
|
|
||||||
int updated = 0;
|
|
||||||
// 进度
|
|
||||||
int uploadPercent = 0;
|
|
||||||
bool isProcessing = false;
|
|
||||||
int processingSeconds = 0;
|
|
||||||
|
|
||||||
_ImportSlot(this.title, this.endpoint, this.hint);
|
|
||||||
|
|
||||||
bool get hasResult => success != null;
|
|
||||||
|
|
||||||
void resetProgress() {
|
|
||||||
uploadPercent = 0;
|
|
||||||
isProcessing = false;
|
|
||||||
processingSeconds = 0;
|
|
||||||
success = null;
|
|
||||||
error = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BatchImportWidget extends ConsumerStatefulWidget {
|
|
||||||
final bool isSuperAdmin;
|
|
||||||
const _BatchImportWidget({required this.isSuperAdmin});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|
||||||
bool _loading = false;
|
|
||||||
String? _lastDir;
|
|
||||||
OverlayEntry? _importBarrier;
|
|
||||||
|
|
||||||
static const _prefKey = 'import_last_dir';
|
|
||||||
|
|
||||||
late final List<_ImportSlot> _slots = [
|
|
||||||
_ImportSlot('往来单位', '/import/partners',
|
|
||||||
'格式:编号 | 类型 | 状态 | 名称 | 电话 | 卡号 | 初始金额 | 单位 | 地址 | 备注'),
|
|
||||||
_ImportSlot('商品名称', '/import/product-names',
|
|
||||||
'格式:选项编号 | 选项名称 | 备注'),
|
|
||||||
_ImportSlot('商品系列', '/import/product-series',
|
|
||||||
'格式:选项编号 | 选项名称 | 备注'),
|
|
||||||
_ImportSlot('商品规格', '/import/product-specs',
|
|
||||||
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
|
|
||||||
_ImportSlot('库存', '/import/inventory',
|
|
||||||
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
|
|
||||||
_ImportSlot('入库单', '/import/stock-in',
|
|
||||||
'老系统入库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
|
|
||||||
_ImportSlot('出库单', '/import/stock-out',
|
|
||||||
'老系统出库单打印格式:每个文件一张单(含单据号/往来单位/日期/明细)'),
|
|
||||||
];
|
|
||||||
|
|
||||||
final Set<String> _selectedTables = {};
|
|
||||||
bool _clearing = false;
|
|
||||||
|
|
||||||
static const _clearOptions = [
|
|
||||||
(key: 'stock_in', label: '入库单'),
|
|
||||||
(key: 'stock_out', label: '出库单'),
|
|
||||||
(key: 'inventory', label: '库存管理'),
|
|
||||||
(key: 'products', label: '商品详情'),
|
|
||||||
(key: 'partners', label: '往来单位'),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
SharedPreferences.getInstance().then((prefs) {
|
|
||||||
final dir = prefs.getString(_prefKey);
|
|
||||||
if (dir != null && mounted) setState(() => _lastDir = dir);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickFile(int index) async {
|
|
||||||
try {
|
|
||||||
final result = await FilePicker.platform.pickFiles(
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: ['xls', 'xlsx'],
|
|
||||||
withData: true,
|
|
||||||
initialDirectory: kIsWeb ? null : _lastDir,
|
|
||||||
);
|
|
||||||
if (result == null || result.files.isEmpty) return;
|
|
||||||
|
|
||||||
// 保存目录供下次使用(仅非 web 平台)
|
|
||||||
if (!kIsWeb) {
|
|
||||||
final path = result.files.first.path;
|
|
||||||
if (path != null) {
|
|
||||||
final dir = path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : null;
|
|
||||||
if (dir != null) {
|
|
||||||
_lastDir = dir;
|
|
||||||
SharedPreferences.getInstance().then((p) => p.setString(_prefKey, dir));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_slots[index].file = result.files.first;
|
|
||||||
_slots[index].success = null;
|
|
||||||
_slots[index].error = null;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('选择文件失败:$e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showBarrier() {
|
|
||||||
_importBarrier = OverlayEntry(
|
|
||||||
builder: (_) => Stack(children: [
|
|
||||||
const ModalBarrier(dismissible: false, color: Colors.transparent),
|
|
||||||
Positioned(
|
|
||||||
bottom: 24, left: 0, right: 0,
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black87,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
SizedBox(width: 14, height: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
|
|
||||||
SizedBox(width: 10),
|
|
||||||
Text('导入中,请勿切换页面…',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 13)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
Overlay.of(context, rootOverlay: true).insert(_importBarrier!);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _removeBarrier() {
|
|
||||||
_importBarrier?.remove();
|
|
||||||
_importBarrier = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runImport() async {
|
|
||||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
|
||||||
if (!_slots.any((s) => s.file != null)) return;
|
|
||||||
|
|
||||||
_showBarrier();
|
|
||||||
setState(() {
|
|
||||||
_loading = true;
|
|
||||||
for (final s in _slots) {
|
|
||||||
if (s.file != null) s.resetProgress();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final dio = Dio(BaseOptions(
|
|
||||||
baseUrl: AppConfig.apiBaseUrl,
|
|
||||||
sendTimeout: AppConstants.importSendTimeout,
|
|
||||||
receiveTimeout: AppConstants.importReceiveTimeout,
|
|
||||||
));
|
|
||||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
|
||||||
|
|
||||||
for (final slot in _slots) {
|
|
||||||
if (slot.file == null) continue;
|
|
||||||
final bytes = slot.file!.bytes;
|
|
||||||
if (bytes == null) {
|
|
||||||
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer? processingTimer;
|
|
||||||
bool uploadDone = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final formData = FormData.fromMap({
|
|
||||||
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
|
|
||||||
});
|
|
||||||
final resp = await dio.post(
|
|
||||||
slot.endpoint,
|
|
||||||
data: formData,
|
|
||||||
onSendProgress: (sent, total) {
|
|
||||||
if (total <= 0 || !mounted) return;
|
|
||||||
if (sent >= total && !uploadDone) {
|
|
||||||
uploadDone = true;
|
|
||||||
setState(() { slot.isProcessing = true; slot.processingSeconds = 0; });
|
|
||||||
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => slot.processingSeconds++);
|
|
||||||
});
|
|
||||||
} else if (!uploadDone) {
|
|
||||||
final pct = (sent / total * 100).round().clamp(0, 99);
|
|
||||||
if (mounted) setState(() => slot.uploadPercent = pct);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
processingTimer?.cancel();
|
|
||||||
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
|
||||||
if (mounted) setState(() {
|
|
||||||
slot.success = true;
|
|
||||||
if (data.containsKey('order_no')) {
|
|
||||||
// 单据类导入(入库单/出库单):每个文件一张单,
|
|
||||||
// 已存在时后端返回 {skipped: true}(布尔),不能按 int 解析。
|
|
||||||
final skippedOne = data['skipped'] == true;
|
|
||||||
slot.imported = skippedOne ? 0 : 1;
|
|
||||||
slot.skipped = skippedOne ? 1 : 0;
|
|
||||||
slot.updated = 0;
|
|
||||||
slot.total = 1;
|
|
||||||
} else {
|
|
||||||
slot.imported = (data['imported'] ?? 0) as int;
|
|
||||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
|
||||||
slot.updated = (data['updated'] ?? 0) as int;
|
|
||||||
slot.total = (data['total'] ?? slot.imported + slot.skipped + slot.updated) as int;
|
|
||||||
}
|
|
||||||
slot.isProcessing = false;
|
|
||||||
});
|
|
||||||
} on DioException catch (e) {
|
|
||||||
processingTimer?.cancel();
|
|
||||||
final raw = e.response?.data;
|
|
||||||
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
|
||||||
if (mounted) setState(() {
|
|
||||||
slot.success = false;
|
|
||||||
slot.error = msg.toString();
|
|
||||||
slot.isProcessing = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
processingTimer?.cancel();
|
|
||||||
if (mounted) setState(() {
|
|
||||||
slot.success = false;
|
|
||||||
slot.error = e.toString();
|
|
||||||
slot.isProcessing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
_removeBarrier();
|
|
||||||
setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final hasAnyFile = _slots.any((s) => s.file != null);
|
|
||||||
final ran = _slots.where((s) => s.file != null && s.hasResult).toList();
|
|
||||||
final allDone = !_loading && ran.length == _slots.where((s) => s.file != null).length && ran.isNotEmpty;
|
|
||||||
final failCount = ran.where((s) => s.success == false).length;
|
|
||||||
|
|
||||||
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(
|
|
||||||
'选择对应的 Excel 文件后点击「全部导入」,系统将依次导入,按名称去重(已存在的数据不重复导入)。',
|
|
||||||
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
for (int i = 0; i < _slots.length; i++) ...[
|
|
||||||
_buildSlotRow(i),
|
|
||||||
if (i < _slots.length - 1)
|
|
||||||
const Divider(height: 1),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: (_loading || !hasAnyFile) ? null : _runImport,
|
|
||||||
icon: _loading
|
|
||||||
? const SizedBox(
|
|
||||||
width: 14, height: 14,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2, color: Colors.white))
|
|
||||||
: const Icon(Icons.upload_rounded, size: 16),
|
|
||||||
label: Text(_loading ? '导入中...' : '全部导入'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
if (allDone) ...[
|
|
||||||
Icon(
|
|
||||||
failCount == 0
|
|
||||||
? Icons.check_circle_outline
|
|
||||||
: Icons.warning_amber_rounded,
|
|
||||||
size: 16,
|
|
||||||
color: failCount == 0 ? AppTheme.success : Colors.orange,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
failCount == 0 ? '全部导入完成' : '$failCount 项失败,请检查错误信息',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: failCount == 0 ? AppTheme.success : Colors.orange,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (widget.isSuperAdmin) ...[
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildClearDataSection(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildClearDataSection() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 标题行
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.delete_forever, color: AppTheme.danger, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text('危险操作 — 数据清空',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.danger)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// 红色警告框
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFFF3F3),
|
|
||||||
border: Border.all(color: AppTheme.danger.withOpacity(0.5)),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(children: [
|
|
||||||
const Icon(Icons.warning_amber_rounded,
|
|
||||||
color: AppTheme.danger, size: 18),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
const Text('警告:数据一旦清空,无法恢复!',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.danger,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13)),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
const Text(
|
|
||||||
'• 清空操作仅删除当前门店的数据,不影响其他门店\n'
|
|
||||||
'• 操作不可撤销,请务必提前备份数据\n'
|
|
||||||
'• 仅超级管理员可执行此操作',
|
|
||||||
style: TextStyle(fontSize: 12, color: Color(0xFFB71C1C), height: 1.6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
// 勾选框
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 4,
|
|
||||||
children: _clearOptions.map((opt) {
|
|
||||||
final selected = _selectedTables.contains(opt.key);
|
|
||||||
return FilterChip(
|
|
||||||
label: Text(opt.label),
|
|
||||||
selected: selected,
|
|
||||||
selectedColor: AppTheme.danger.withOpacity(0.15),
|
|
||||||
checkmarkColor: AppTheme.danger,
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: selected ? AppTheme.danger : AppTheme.textPrimary,
|
|
||||||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
),
|
|
||||||
side: BorderSide(
|
|
||||||
color: selected ? AppTheme.danger : Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
onSelected: _clearing
|
|
||||||
? null
|
|
||||||
: (v) => setState(() {
|
|
||||||
if (v) {
|
|
||||||
_selectedTables.add(opt.key);
|
|
||||||
} else {
|
|
||||||
_selectedTables.remove(opt.key);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
// 清空按钮
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: (_clearing || _selectedTables.isEmpty)
|
|
||||||
? null
|
|
||||||
: () => _confirmClear(),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.danger,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
disabledBackgroundColor: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
icon: _clearing
|
|
||||||
? const SizedBox(
|
|
||||||
width: 14, height: 14,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
||||||
: const Icon(Icons.delete_sweep_outlined, size: 18),
|
|
||||||
label: Text(_clearing ? '清空中...' : '清空选中数据'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _confirmClear() async {
|
|
||||||
final tables = List<String>.from(_selectedTables);
|
|
||||||
final labels = _clearOptions
|
|
||||||
.where((o) => tables.contains(o.key))
|
|
||||||
.map((o) => o.label)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final ctrl = TextEditingController();
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (ctx) => StatefulBuilder(
|
|
||||||
builder: (ctx, setS) => AlertDialog(
|
|
||||||
title: Row(children: [
|
|
||||||
const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text('确认清空数据', style: TextStyle(color: Colors.red)),
|
|
||||||
]),
|
|
||||||
content: SizedBox(
|
|
||||||
width: context.dialogWidth(400),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('即将清空以下数据表:',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w500)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
...labels.map((l) => Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 12, bottom: 4),
|
|
||||||
child: Row(children: [
|
|
||||||
const Icon(Icons.remove_circle,
|
|
||||||
color: Colors.red, size: 14),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(l,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.red, fontWeight: FontWeight.w600)),
|
|
||||||
]),
|
|
||||||
)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFFF3F3),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(color: Colors.red.shade200),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'⚠️ 此操作不可撤销,数据清空后无法恢复!',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('请输入 "确认清空" 以继续:',
|
|
||||||
style: TextStyle(fontSize: 13)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextField(
|
|
||||||
controller: ctrl,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '确认清空',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (_) => setS(() {}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('取消'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: ctrl.text == '确认清空'
|
|
||||||
? () => Navigator.of(ctx).pop(true)
|
|
||||||
: null,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
child: const Text('确认清空'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
ctrl.dispose();
|
|
||||||
if (confirmed != true || !mounted) return;
|
|
||||||
|
|
||||||
setState(() => _clearing = true);
|
|
||||||
try {
|
|
||||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
|
||||||
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
|
|
||||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
|
||||||
await dio.post('/admin/clear-data', data: {'tables': tables});
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _selectedTables.clear());
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('已清空:${labels.join('、')}'),
|
|
||||||
backgroundColor: AppTheme.success,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ??
|
|
||||||
e.message ?? '未知错误';
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('清空失败:$msg'), backgroundColor: AppTheme.danger),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('清空失败:$e'), backgroundColor: AppTheme.danger),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _clearing = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSlotRow(int index) {
|
|
||||||
final slot = _slots[index];
|
|
||||||
|
|
||||||
Widget? statusWidget;
|
|
||||||
if (slot.hasResult) {
|
|
||||||
if (slot.success == true) {
|
|
||||||
final duplicate = slot.skipped + slot.updated;
|
|
||||||
final parts = <String>[
|
|
||||||
'共 ${slot.total} 条',
|
|
||||||
'新增 ${slot.imported} 条',
|
|
||||||
if (duplicate > 0) '重复 $duplicate 条',
|
|
||||||
];
|
|
||||||
statusWidget = Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.check_circle_outline, size: 15, color: AppTheme.success),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
parts.join(','),
|
|
||||||
style: const TextStyle(fontSize: 13, color: AppTheme.success),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
statusWidget = Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error_outline, size: 15, color: AppTheme.danger),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
slot.error ?? '未知错误',
|
|
||||||
style: const TextStyle(fontSize: 13, color: AppTheme.danger),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (_loading && slot.file != null) {
|
|
||||||
if (slot.isProcessing) {
|
|
||||||
statusWidget = Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 12, height: 12,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'导入数据${slot.processingSeconds > 0 ? "(${slot.processingSeconds}秒)" : ""}',
|
|
||||||
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
statusWidget = Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 80,
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: slot.uploadPercent / 100,
|
|
||||||
backgroundColor: Colors.grey[200],
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
|
||||||
minHeight: 6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'上传 ${slot.uploadPercent}%',
|
|
||||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 68,
|
|
||||||
child: Text(slot.title,
|
|
||||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: _loading ? null : () => _pickFile(index),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
minimumSize: Size.zero,
|
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
),
|
|
||||||
child: const Text('选择文件', style: TextStyle(fontSize: 13)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
slot.file?.name ?? '未选择',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: slot.file != null ? AppTheme.textPrimary : AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
if (statusWidget != null) statusWidget,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 80),
|
|
||||||
child: Text(
|
|
||||||
slot.hint,
|
|
||||||
style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 门店 Logo 预览 ────────────────────────────────────────
|
// ── 门店 Logo 预览 ────────────────────────────────────────
|
||||||
class _ShopLogoPreview extends StatelessWidget {
|
class _ShopLogoPreview extends StatelessWidget {
|
||||||
|
|||||||
@@ -1064,16 +1064,18 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
|||||||
0: FixedColumnWidth(36),
|
0: FixedColumnWidth(36),
|
||||||
1: FlexColumnWidth(1.2),
|
1: FlexColumnWidth(1.2),
|
||||||
2: FlexColumnWidth(2.2),
|
2: FlexColumnWidth(2.2),
|
||||||
3: FlexColumnWidth(1.5),
|
3: FlexColumnWidth(1.3),
|
||||||
4: FlexColumnWidth(1.5),
|
4: FlexColumnWidth(1.3),
|
||||||
5: FlexColumnWidth(1.2),
|
5: FlexColumnWidth(1.3),
|
||||||
6: FlexColumnWidth(1.2),
|
6: FlexColumnWidth(1.3),
|
||||||
7: FlexColumnWidth(1.2),
|
7: FlexColumnWidth(1.0),
|
||||||
|
8: FlexColumnWidth(1.1),
|
||||||
|
9: FlexColumnWidth(1.1),
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
TableRow(
|
TableRow(
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||||
children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额']
|
children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '单价', '金额']
|
||||||
.map((h) => Padding(
|
.map((h) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 8, vertical: 10),
|
horizontal: 8, vertical: 10),
|
||||||
@@ -1122,6 +1124,12 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
|
|||||||
),
|
),
|
||||||
_TableCell(item.productSeries ?? '-'),
|
_TableCell(item.productSeries ?? '-'),
|
||||||
_TableCell(item.productSpec ?? '-'),
|
_TableCell(item.productSpec ?? '-'),
|
||||||
|
_TableCell((item.productionDate != null &&
|
||||||
|
item.productionDate!.length >= 10)
|
||||||
|
? item.productionDate!.substring(0, 10)
|
||||||
|
: (item.productionDate ?? '-')),
|
||||||
|
_TableCell(
|
||||||
|
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
|
||||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
Set<String> _filterCustomer = {};
|
Set<String> _filterCustomer = {};
|
||||||
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||||
final _searchCtrl = TextEditingController();
|
final _searchCtrl = TextEditingController();
|
||||||
|
final _codeSearchCtrl = TextEditingController();
|
||||||
|
|
||||||
static const _screenId = 'stock_out_list';
|
static const _screenId = 'stock_out_list';
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
|
_codeSearchCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +86,10 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
.read(stockOutListProvider.notifier)
|
.read(stockOutListProvider.notifier)
|
||||||
.setKeyword(_searchCtrl.text.trim());
|
.setKeyword(_searchCtrl.text.trim());
|
||||||
|
|
||||||
|
void _triggerCodeSearch() => ref
|
||||||
|
.read(stockOutListProvider.notifier)
|
||||||
|
.setProductCode(_codeSearchCtrl.text.trim());
|
||||||
|
|
||||||
String? get _startDate => _dateRange != null
|
String? get _startDate => _dateRange != null
|
||||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||||
: null;
|
: null;
|
||||||
@@ -384,6 +390,22 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
onSubmitted: (_) => _triggerSearch(),
|
onSubmitted: (_) => _triggerSearch(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 商品编码精确反查(独立搜索框)
|
||||||
|
final codeSearchField = TextField(
|
||||||
|
controller: _codeSearchCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '商品编码,回车精确查',
|
||||||
|
prefixIcon: const Icon(Icons.qr_code, size: 16),
|
||||||
|
hintStyle: const TextStyle(fontSize: 12),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.search, size: 16),
|
||||||
|
tooltip: '按商品编码查',
|
||||||
|
onPressed: _triggerCodeSearch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _triggerCodeSearch(),
|
||||||
|
);
|
||||||
|
|
||||||
final refreshBtn = IconButton(
|
final refreshBtn = IconButton(
|
||||||
tooltip: '刷新',
|
tooltip: '刷新',
|
||||||
icon: const Icon(Icons.refresh, size: 20),
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
@@ -417,6 +439,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
children: [
|
children: [
|
||||||
searchField,
|
searchField,
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
codeSearchField,
|
||||||
|
const SizedBox(height: 8),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 4,
|
runSpacing: 4,
|
||||||
@@ -450,6 +474,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(width: 220, child: searchField),
|
SizedBox(width: 220, child: searchField),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(width: 180, child: codeSearchField),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
if (newBtn != null) newBtn,
|
if (newBtn != null) newBtn,
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -1003,16 +1029,18 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
|||||||
0: FixedColumnWidth(36),
|
0: FixedColumnWidth(36),
|
||||||
1: FlexColumnWidth(1.2),
|
1: FlexColumnWidth(1.2),
|
||||||
2: FlexColumnWidth(2.2),
|
2: FlexColumnWidth(2.2),
|
||||||
3: FlexColumnWidth(1.5),
|
3: FlexColumnWidth(1.3),
|
||||||
4: FlexColumnWidth(1.5),
|
4: FlexColumnWidth(1.3),
|
||||||
5: FlexColumnWidth(1.2),
|
5: FlexColumnWidth(1.3),
|
||||||
6: FlexColumnWidth(1.2),
|
6: FlexColumnWidth(1.3),
|
||||||
7: FlexColumnWidth(1.2),
|
7: FlexColumnWidth(1.0),
|
||||||
|
8: FlexColumnWidth(1.1),
|
||||||
|
9: FlexColumnWidth(1.1),
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
TableRow(
|
TableRow(
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||||
children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额']
|
children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '单价', '金额']
|
||||||
.map((h) => Padding(
|
.map((h) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||||
child: Text(h,
|
child: Text(h,
|
||||||
@@ -1035,6 +1063,12 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
|
|||||||
_TableCell(item.productName ?? '-'),
|
_TableCell(item.productName ?? '-'),
|
||||||
_TableCell(item.productSeries ?? '-'),
|
_TableCell(item.productSeries ?? '-'),
|
||||||
_TableCell(item.productSpec ?? '-'),
|
_TableCell(item.productSpec ?? '-'),
|
||||||
|
_TableCell((item.productionDate != null &&
|
||||||
|
item.productionDate!.length >= 10)
|
||||||
|
? item.productionDate!.substring(0, 10)
|
||||||
|
: (item.productionDate ?? '-')),
|
||||||
|
_TableCell(
|
||||||
|
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
|
||||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||||
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'),
|
||||||
|
|||||||
Reference in New Issue
Block a user