Compare commits

...

6 Commits

Author SHA1 Message Date
wangjia d5dc499c6b fix(import): 修复导入 504 超时 + 添加上传进度显示
Deploy / deploy (push) Successful in 1m15s
nginx:
- 新增 /api/v1/import/ 专属 location,proxy_read_timeout 延长至 300s

后端:
- ImportInventory 预加载商品/仓库/库存(3 次 bulk query 替代 N+1)
- 批量写 inventory_log(CreateInBatches 替代逐行 Create)

前端:
- settings 导入行:显示"上传 XX%"进度条和"导入数据(Ns)"计时
- Dio 超时设置为 send=120s / receive=300s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:12:59 +08:00
wangjia 5725e84971 fix(nginx): 增加上传文件大小限制至 20MB
Deploy / deploy (push) Successful in 1m16s
默认 1MB 导致 Excel 文件上传返回 413。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:03:21 +08:00
wangjia 86baaf3a1c fix(model): users 唯一索引改为 (shop_id, username) 复合索引
Deploy / deploy (push) Successful in 1m13s
原来 uniqueIndex:uk_shop_username 只在 Username 字段上,导致用户名全局唯一。
S002/S003 的 admin 用户与 S001 冲突,INSERT 被静默跳过,无法登录。

将 User 的 TenantBase 改为直接定义 ShopID,使其参与复合唯一索引。
需要 Reset DB 使新索引生效。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:50:34 +08:00
wangjia fcf92675b1 fix(seeds): 修复 clear_shop.sql collation 冲突
Deploy / deploy (push) Successful in 1m13s
用 BINARY 比较绕过 utf8mb4_unicode_ci 与 utf8mb4_0900_ai_ci 的冲突。
shop code 均为纯 ASCII,BINARY 比较结果完全一致。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:34:14 +08:00
wangjia d81b2d1fac fix(seed.yml): 两步走避免 S001 因 id 冲突被跳过
Deploy / deploy (push) Successful in 1m17s
清空和写入分两个独立循环:先清空所有门店,再依次写入。
避免 S001(硬编码 id=1) 在 S002 尚未清除时被 INSERT IGNORE 跳过。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:30:46 +08:00
wangjia acf2b22201 fix(seeds): 修复多门店种子数据互不干扰
Deploy / deploy (push) Successful in 1m14s
- S001.sql: 修复 INSERT IGNORE INTO 缺少空格的语法错误
- S002.sql: 重写为动态 @shop_id 模式,移除 TRUNCATE,改用 INSERT IGNORE INTO
- clear_shop.sql: 新增按 @shop_code 清空单店数据的参数化脚本
- scripts/dev.sh: seed 命令支持 --clear 参数(先清空再写入)
- seed.yml: 新增 clear 输入参数,支持按需清空后写入

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:52:47 +08:00
13 changed files with 572 additions and 229 deletions
+20
View File
@@ -7,6 +7,10 @@ on:
description: '要写入的门店(逗号分隔,如 S001,S002,S003'
required: true
default: 'S001,S002,S003'
clear:
description: '写入前先清空各门店数据(true/false'
required: false
default: 'false'
jobs:
seed:
@@ -27,7 +31,23 @@ jobs:
EC2_USER: ${{ secrets.EC2_USER }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
SHOPS: ${{ github.event.inputs.shops }}
CLEAR: ${{ github.event.inputs.clear }}
run: |
# 第一步:若需要清空,先把所有门店都清掉再写入
# 避免 S001(id=1 硬编码) 因 S002 占用 id=1 而被 INSERT IGNORE 跳过
if [ "$CLEAR" = "true" ]; then
echo "=== 清空阶段 ==="
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
echo " 清空 ${SHOP}..."
printf "SET @shop_code='%s';\n" "$SHOP" | cat - backend/seeds/clear_shop.sql | \
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} --default-character-set=utf8mb4 jiu_db"
done
echo "✓ 清空完成"
fi
# 第二步:依次写入所有门店
echo "=== 写入阶段 ==="
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
FILE="backend/seeds/${SHOP}.sql"
if [ ! -f "$FILE" ]; then
+3 -3
View File
@@ -132,21 +132,21 @@ func main() {
hash := mustHash("password123")
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
return &model.User{
TenantBase: model.TenantBase{ShopID: shop.ID},
ShopID: shop.ID,
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
Phone: "13800000001", Role: "admin", IsActive: true,
}
}).(model.User)
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
return &model.User{
TenantBase: model.TenantBase{ShopID: shop.ID},
ShopID: shop.ID,
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
Phone: "13800000002", Role: "operator", IsActive: true,
}
}).(model.User)
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
return &model.User{
TenantBase: model.TenantBase{ShopID: shop.ID},
ShopID: shop.ID,
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
Phone: "13800000003", Role: "readonly", IsActive: true,
}
+88 -64
View File
@@ -519,23 +519,47 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
var res importResult
// 仓库缓存,只查找不创建
warehouseCache := map[string]*uint64{}
// 预加载仓库(1 次查询)
var warehouses []model.Warehouse
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
warehouseByName := make(map[string]uint64, len(warehouses))
for _, wh := range warehouses {
warehouseByName[wh.Name] = wh.ID
}
findWarehouse := func(name string) *uint64 {
if name == "" {
return nil
}
if idPtr, ok := warehouseCache[name]; ok {
return idPtr
if id, ok := warehouseByName[name]; ok {
return &id
}
var wh model.Warehouse
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
warehouseCache[name] = nil
return nil
return nil
}
// 预加载商品(1 次查询)
var allProducts []model.Product
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&allProducts)
productByCode := make(map[string]*model.Product, len(allProducts))
productByNSS := make(map[string]*model.Product, len(allProducts))
for i := range allProducts {
p := &allProducts[i]
if p.Code != "" {
productByCode[p.Code] = p
}
id := wh.ID
warehouseCache[name] = &id
return &id
productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p
}
// 预加载已有导入库存(1 次查询)
var allInvs []model.Inventory
h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs)
invByKey := make(map[string]*model.Inventory, len(allInvs))
for i := range allInvs {
inv := &allInvs[i]
whID := uint64(0)
if inv.WarehouseID != nil {
whID = *inv.WarehouseID
}
invByKey[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
}
// Dynamic column detection from header row
@@ -561,6 +585,8 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
}
var logsToCreate []model.InventoryLog
for i, row := range rows[1:] {
productName := cell(row, 1)
if productName == "" {
@@ -586,20 +612,30 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
}
price, _ := strconv.ParseFloat(priceStr, 64)
// 只查找商品,不强制创建
var prod model.Product
if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))",
shopID, productCode, productName, series, spec).First(&prod).Error != nil {
// 若找不到则创建
// 从缓存查商品,找不到才创建
var prod *model.Product
if productCode != "" {
prod = productByCode[productCode]
}
if prod == nil {
prod = productByNSS[productName+"|"+series+"|"+spec]
}
if prod == nil {
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
if createErr != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
continue
}
prod = newProd
allProducts = append(allProducts, newProd)
prod = &allProducts[len(allProducts)-1]
if prod.Code != "" {
productByCode[prod.Code] = prod
}
productByNSS[prod.Name+"|"+prod.Series+"|"+prod.Spec] = prod
}
if unit != "" && prod.Unit == "" {
h.db.Model(&prod).Update("unit", unit)
h.db.Model(prod).Update("unit", unit)
prod.Unit = unit
}
// 解析生产日期
@@ -609,7 +645,6 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
productionDate = &d
}
// 查找仓库(只查,不创建)
whIDPtr := findWarehouse(warehouseName)
var unitPricePtr *float64
@@ -617,30 +652,25 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
unitPricePtr = &price
}
productIDCopy := prod.ID
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
var existing model.Inventory
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
shopID, prod.Code)
// 从缓存查库存记录
whIDVal := uint64(0)
if whIDPtr != nil {
q = q.Where("warehouse_id = ?", *whIDPtr)
} else {
q = q.Where("warehouse_id IS NULL")
whIDVal = *whIDPtr
}
found := q.First(&existing).Error == nil
invKey := fmt.Sprintf("%s|%d", prod.Code, whIDVal)
existing := invByKey[invKey]
if found {
if existing != nil {
updates := map[string]interface{}{
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
"quantity": qty,
"product_name": prod.Name,
"series": prod.Series,
"spec": prod.Spec,
"unit": prod.Unit,
"warehouse_name": warehouseName,
"supplier_name": supplierName,
"remark": remark,
"deleted_at": nil,
}
if unitPricePtr != nil {
updates["unit_price"] = *unitPricePtr
@@ -651,11 +681,18 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
if batchNo != "" {
updates["batch_no"] = batchNo
}
if err := h.db.Model(&existing).Updates(updates).Error; err != nil {
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
continue
}
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
RefType: "import", RefID: 0,
})
res.updated++
} else {
productIDCopy := prod.ID
inv := model.Inventory{
ShopID: shopID,
WarehouseID: whIDPtr,
@@ -678,32 +715,19 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
continue
}
}
// 写流水
warehouseID := uint64(0)
if whIDPtr != nil {
warehouseID = *whIDPtr
}
qtyBefore := 0.0
if found {
qtyBefore = existing.Quantity
res.updated++
} else {
invByKey[invKey] = &inv
logsToCreate = append(logsToCreate, model.InventoryLog{
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty,
RefType: "import", RefID: 0,
})
res.imported++
}
log := model.InventoryLog{
ShopID: shopID,
WarehouseID: warehouseID,
ProductID: prod.ID,
Direction: "in",
Quantity: qty,
QtyBefore: qtyBefore,
QtyAfter: qty,
RefType: "import",
RefID: 0,
}
h.db.Create(&log)
}
// 批量写流水
if len(logsToCreate) > 0 {
h.db.CreateInBatches(&logsToCreate, 100)
}
c.JSON(http.StatusOK, gin.H{
+1 -1
View File
@@ -52,7 +52,7 @@ func (h *UserHandler) Create(c *gin.Context) {
role = "operator"
}
u := model.User{
TenantBase: model.TenantBase{ShopID: shopID},
ShopID: shopID,
Username: req.Username,
PasswordHash: string(hash),
RealName: req.RealName,
+2 -1
View File
@@ -1,7 +1,8 @@
package model
type User struct {
TenantBase
Base
ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"`
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
PasswordHash string `gorm:"size:255" json:"-"`
RealName string `gorm:"size:50" json:"real_name"`
+38 -60
View File
@@ -2,39 +2,15 @@
-- 测试门店 S001 种子数据
-- 用法: sh scripts/dev.sh seed S001
-- 前置条件: 数据库表结构已创建(启动一次后端即可)
-- 说明: 每次执行会清空并重新写入所有业务数据
-- 说明: 仅写入数据,不清空。加 --clear 参数先清空再写入
-- =============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ── 清空(子表先清)────────────────────────────────────────
TRUNCATE TABLE inventory_check_items;
TRUNCATE TABLE inventory_checks;
TRUNCATE TABLE inventory_logs;
TRUNCATE TABLE inventories;
TRUNCATE TABLE stock_out_items;
TRUNCATE TABLE stock_out_orders;
TRUNCATE TABLE stock_in_items;
TRUNCATE TABLE stock_in_orders;
TRUNCATE TABLE finance_records;
TRUNCATE TABLE number_rules;
TRUNCATE TABLE product_images;
TRUNCATE TABLE partners;
TRUNCATE TABLE warehouses;
TRUNCATE TABLE products;
TRUNCATE TABLE product_spec_options;
TRUNCATE TABLE product_series_options;
TRUNCATE TABLE product_name_options;
TRUNCATE TABLE product_categories;
TRUNCATE TABLE users;
TRUNCATE TABLE shops;
SET FOREIGN_KEY_CHECKS = 1;
-- ── 门店 ────────────────────────────────────────────────────
-- id=1
INSERT INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
INSERT IGNORE INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
VALUES (1, '盛世名酿酒行', 'S001', '北京市朝阳区建国路88号华贸中心B座101室', '010-65882266', '张建国', NOW(), NOW());
-- ── 用户(密码均为 password123)────────────────────────────
@@ -42,26 +18,26 @@ VALUES (1, '盛世名酿酒行', 'S001', '北京市朝阳区建国路88号华贸
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
-- id=1 admin, id=2 operator, id=3 readonly
INSERT INTO users (id, shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at) VALUES
INSERT IGNORE INTO users (id, shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at) VALUES
(1, 1, 'admin', @pwd, '张三(管理员)', '13800000001', 'admin', 1, NOW(), NOW()),
(2, 1, 'operator', @pwd, '李四(操作员)', '13800000002', 'operator', 1, NOW(), NOW()),
(3, 1, 'test', @pwd, '王五(只读)', '13800000003', 'readonly', 1, NOW(), NOW());
-- ── 仓库 ────────────────────────────────────────────────────
-- id=1 主仓库, id=2 进口酒专库
INSERT INTO warehouses (id, shop_id, name, location, is_default, created_at, updated_at) VALUES
INSERT IGNORE INTO warehouses (id, shop_id, name, location, is_default, created_at, updated_at) VALUES
(1, 1, '主仓库', 'A栋1层东侧', 1, NOW(), NOW()),
(2, 1, '进口酒专库', 'B栋2层恒温区', 0, NOW(), NOW());
-- ── 商品分类 ────────────────────────────────────────────────
-- id=1 白酒, id=2 进口烈酒
INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updated_at) VALUES
INSERT IGNORE INTO product_categories (id, shop_id, name, sort_order, created_at, updated_at) VALUES
(1, 1, '白酒', 1, NOW(), NOW()),
(2, 1, '进口烈酒', 2, NOW(), NOW());
-- ── 商品 ────────────────────────────────────────────────────
-- id=1~6 白酒, id=7~8 进口烈酒
INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
INSERT IGNORE INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
purchase_price, sale_price, min_stock, remark, description, created_at, updated_at) VALUES
(1, 1, 'a1b2c3d4-0001-0001-0001-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', '飞天茅台采用优质高粱、小麦,经传统酱香工艺精心酿制。酒体醇厚丰满,酱香突出,幽雅细腻,空杯留香持久。53度黄金度数,是馈赠佳品的首选。', NOW(), NOW()),
(2, 1, 'a1b2c3d4-0001-0001-0001-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', '五粮液以高粱、大米、糯米、小麦、玉米五种粮食为原料,经地窖发酵精酿而成。香气悠久,味醇厚,入口甘美,入喉净爽,浓香典范。', NOW(), NOW()),
@@ -73,7 +49,7 @@ INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec,
(8, 1, 'a1b2c3d4-0001-0001-0001-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP', '人头马', '700ml/瓶', '', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', '人头马VSOP精选法国干邑地区Fine Champagne产区葡萄,经二次蒸馏后在法国橡木桶中陈年至少四年。口感丝滑,带有香草、杏干与蜂蜜的复杂香气,是干邑入门经典。', NOW(), NOW());
-- ── 商品名称选项 ────────────────────────────────────────────
INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES
INSERT IGNORE INTO product_name_options (shop_id, code, name, created_at, updated_at) VALUES
(1, 'NA001', '飞天茅台 53度', NOW(), NOW()),
(1, 'NA002', '五粮液 52度', NOW(), NOW()),
(1, 'NA003', '洋河梦之蓝 M6', NOW(), NOW()),
@@ -84,7 +60,7 @@ INSERT INTO product_name_options (shop_id, code, name, created_at, updated_at) V
(1, 'NA008', '人头马 VSOP', NOW(), NOW());
-- ── 商品系列选项 ────────────────────────────────────────────
INSERT INTO product_series_options (shop_id, code, name, created_at, updated_at) VALUES
INSERT IGNORE INTO product_series_options (shop_id, code, name, created_at, updated_at) VALUES
(1, 'SE001', '茅台', NOW(), NOW()),
(1, 'SE002', '五粮液', NOW(), NOW()),
(1, 'SE003', '洋河', NOW(), NOW()),
@@ -95,14 +71,14 @@ INSERT INTO product_series_options (shop_id, code, name, created_at, updated_at)
(1, 'SE008', '人头马', NOW(), NOW());
-- ── 商品规格选项 ────────────────────────────────────────────
INSERT INTO product_spec_options (shop_id, code, name, quantity, created_at, updated_at) VALUES
INSERT IGNORE INTO product_spec_options (shop_id, code, name, quantity, created_at, updated_at) VALUES
(1, 'GG001', '500ml/瓶', 1, NOW(), NOW()),
(1, 'GG002', '750ml/瓶', 1, NOW(), NOW()),
(1, 'GG003', '700ml/瓶', 1, NOW(), NOW());
-- ── 往来单位 ────────────────────────────────────────────────
-- id=1~4 供应商, id=5~7 客户
INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account,
INSERT IGNORE INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account,
credit_limit, balance, remark, created_at, updated_at) VALUES
(1, 1, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
(2, 1, 'SUP002', '四川五粮液股份有限公司', 'supplier', '王总监', '0831-33333001', '四川省宜宾市翠屏区', '建设银行宜宾支行 6227 0000 0001 0002', 3000000, 0, '五粮液系列授权经销', NOW(), NOW()),
@@ -113,125 +89,125 @@ INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, ba
(7, 1, 'CUS003', '广州白天鹅宾馆', 'customer', '吴采购', '020-81886001', '广州市荔湾区沙面南街1号', '招商银行广州荔湾支行 6225 0000 0002 0003', 300000, 0, '高端宾馆,月结30天', NOW(), NOW());
-- ── 编号规则 ────────────────────────────────────────────────
INSERT INTO number_rules (id, shop_id, type, prefix, current_no, date_format, updated_at) VALUES
INSERT IGNORE INTO number_rules (id, shop_id, type, prefix, current_no, date_format, updated_at) VALUES
(1, 1, 'stock_in', 'RK', 5, 'YYYYMMDD', NOW()),
(2, 1, 'stock_out', 'CK', 4, 'YYYYMMDD', NOW()),
(3, 1, 'inventory_check', 'PD', 1, 'YYYYMMDD', NOW());
-- ── 入库单 ──────────────────────────────────────────────────
-- #1 已审核: 飞天茅台+五粮液,主仓库,茅台供应商 | 总额=339000
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(1, 1, 'RK20260401001', 'purchase', 1, 1, 1,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 6 DAY), 339000,
DATE_SUB(NOW(), INTERVAL 5 DAY), '茅台系列首批入库,含飞天茅台及五粮液', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(1, 1, 1, 1, 120, 2350, 282000, 'BATCH-MT-2024001', '飞天茅台 2024年第一批次', NOW(), NOW()),
(2, 1, 1, 2, 60, 950, 57000, 'BATCH-WLY-2024001', '五粮液普五 2024年批次', NOW(), NOW());
-- #2 已审核: 洋河+泸州+剑南春,主仓库,五粮液供应商 | 总额=51360
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(2, 1, 'RK20260403001', 'purchase', 1, 2, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 4 DAY), 51360,
DATE_SUB(NOW(), INTERVAL 3 DAY), '浓香型白酒补货入库', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(3, 2, 1, 3, 48, 560, 26880, 'BATCH-YH-2024003', '洋河梦之蓝 M6 3月批次', NOW(), NOW()),
(4, 2, 1, 4, 36, 420, 15120, 'BATCH-LZ-2024002', '泸州老窖特曲 春季批次', NOW(), NOW()),
(5, 2, 1, 5, 24, 390, 9360, 'BATCH-JNC-2024001', '剑南春水晶剑 首批', NOW(), NOW());
-- #3 已审核: 拉菲+人头马,进口酒专库,郎酒供应商(代理进口)| 总额=108480
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(3, 1, 'RK20260404001', 'purchase', 2, 4, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 108480,
DATE_SUB(NOW(), INTERVAL 2 DAY), '进口烈酒专库入库,含拉菲及人头马', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(6, 3, 1, 7, 24, 3800, 91200, 'BATCH-LF-2018001', '拉菲古堡2018,原箱', NOW(), NOW()),
(7, 3, 1, 8, 36, 480, 17280, 'BATCH-RTM-2024001', '人头马VSOP2024年进口', NOW(), NOW());
-- #4 待审核: 郎酒补货,主仓库 | 总额=19200
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(4, 1, 'RK20260406001', 'purchase', 1, 4, 2,
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 19200,
'郎酒红花郎补货,待仓库管理员审核', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(8, 4, 1, 6, 60, 320, 19200, 'BATCH-LJ-2024002', '郎酒红花郎10 第二批次', NOW(), NOW());
-- #5 草稿: 追加茅台,主仓库 | 总额=142800
INSERT INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_in_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(5, 1, 'RK20260407001', 'purchase', 1, 1, 2,
'draft', CURDATE(), 142800,
'茅台追加订货,草稿中', NOW(), NOW());
INSERT INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_in_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
batch_no, remark, created_at, updated_at) VALUES
(9, 5, 1, 1, 60, 2380, 142800, 'BATCH-MT-2024002', '飞天茅台 第二批次', NOW(), NOW());
-- ── 出库单 ──────────────────────────────────────────────────
-- #1 已审核: 君悦大酒店,主仓库 | 总额=48960
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(1, 1, 'CK20260404001', 'sale', 1, 5, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 48960,
DATE_SUB(NOW(), INTERVAL 2 DAY), '北京君悦大酒店 4月份定期供货', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(1, 1, 1, 1, 12, 2800, 33600, '飞天茅台 12瓶,整箱出库', NOW(), NOW()),
(2, 1, 1, 2, 6, 1200, 7200, '五粮液普五 6瓶', NOW(), NOW()),
(3, 1, 1, 3, 12, 680, 8160, '洋河梦之蓝 M6 12瓶', NOW(), NOW());
-- #2 已审核: 外滩华尔道夫,进口酒专库 | 总额=39360
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
(2, 1, 'CK20260405001', 'sale', 2, 6, 2,
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 2 DAY), 39360,
DATE_SUB(NOW(), INTERVAL 1 DAY), '上海外滩华尔道夫 进口酒专属采购', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(4, 2, 1, 7, 6, 5200, 31200, '拉菲古堡2018 6瓶', NOW(), NOW()),
(5, 2, 1, 8, 12, 680, 8160, '人头马VSOP 12瓶', NOW(), NOW());
-- #3 待审核: 广州白天鹅宾馆,主仓库 | 总额=23280
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(3, 1, 'CK20260406001', 'sale', 1, 7, 2,
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 23280,
'广州白天鹅宾馆 月度供货申请', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(6, 3, 1, 4, 24, 520, 12480, '泸州老窖特曲 24瓶', NOW(), NOW()),
(7, 3, 1, 5, 12, 480, 5760, '剑南春水晶剑 12瓶', NOW(), NOW()),
(8, 3, 1, 6, 12, 420, 5040, '郎酒红花郎10 12瓶', NOW(), NOW());
-- #4 草稿: 君悦追加,主仓库 | 总额=16800
INSERT INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
INSERT IGNORE INTO stock_out_orders (id, shop_id, order_no, type, warehouse_id, partner_id, operator_id,
status, order_date, total_amount, remark, created_at, updated_at) VALUES
(4, 1, 'CK20260407001', 'sale', 1, 5, 2,
'draft', CURDATE(), 16800,
'北京君悦追加订单,草稿中', NOW(), NOW());
INSERT INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
INSERT IGNORE INTO stock_out_items (id, order_id, shop_id, product_id, quantity, unit_price, total_price,
remark, created_at, updated_at) VALUES
(9, 4, 1, 1, 6, 2800, 16800, '飞天茅台 追加 6瓶', NOW(), NOW());
-- ── 库存(已审核单据计算后的实时库存)────────────────────
-- wh1: 飞天茅台 120-12=108, 五粮液 60-6=54, 洋河 48-12=36, 泸州 36, 剑南春 24
-- wh2: 拉菲 24-6=18, 人头马 36-12=24
INSERT INTO inventories (id, shop_id, warehouse_id, product_id, quantity, updated_at) VALUES
INSERT IGNORE INTO inventories (id, shop_id, warehouse_id, product_id, quantity, updated_at) VALUES
(1, 1, 1, 1, 108, NOW()),
(2, 1, 1, 2, 54, NOW()),
(3, 1, 1, 3, 36, NOW()),
@@ -242,33 +218,33 @@ INSERT INTO inventories (id, shop_id, warehouse_id, product_id, quantity, update
-- ── 库存流水 ────────────────────────────────────────────────
-- 入库单 #1 (ref_id=1): 飞天茅台+五粮液 → wh1
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 1, 1, 1, 1, 'in', 120, 0, 120, 'stock_in', 1, 1, NOW()),
( 2, 1, 1, 2, 'in', 60, 0, 60, 'stock_in', 1, 1, NOW());
-- 入库单 #2 (ref_id=2): 洋河+泸州+剑南春 → wh1
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 3, 1, 1, 3, 'in', 48, 0, 48, 'stock_in', 2, 1, NOW()),
( 4, 1, 1, 4, 'in', 36, 0, 36, 'stock_in', 2, 1, NOW()),
( 5, 1, 1, 5, 'in', 24, 0, 24, 'stock_in', 2, 1, NOW());
-- 入库单 #3 (ref_id=3): 拉菲+人头马 → wh2
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 6, 1, 2, 7, 'in', 24, 0, 24, 'stock_in', 3, 1, NOW()),
( 7, 1, 2, 8, 'in', 36, 0, 36, 'stock_in', 3, 1, NOW());
-- 出库单 #1 (ref_id=1): 飞天茅台+五粮液+洋河 ← wh1
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
( 8, 1, 1, 1, 'out', 12, 120, 108, 'stock_out', 1, 1, NOW()),
( 9, 1, 1, 2, 'out', 6, 60, 54, 'stock_out', 1, 1, NOW()),
(10, 1, 1, 3, 'out', 12, 48, 36, 'stock_out', 1, 1, NOW());
-- 出库单 #2 (ref_id=2): 拉菲+人头马 ← wh2
INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
INSERT IGNORE INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, quantity, qty_before, qty_after,
ref_type, ref_id, operator_id, created_at) VALUES
(11, 1, 2, 7, 'out', 6, 24, 18, 'stock_out', 2, 1, NOW()),
(12, 1, 2, 8, 'out', 12, 36, 24, 'stock_out', 2, 1, NOW());
@@ -278,7 +254,7 @@ INSERT INTO inventory_logs (id, shop_id, warehouse_id, product_id, direction, qu
-- 收款:出库单 #1 已回款
-- 应付账款:入库单 #1 (茅台 339000) + #2 (五粮液代理 51360) + #3 (郎酒 108480)
-- 付款:入库单 #1 已付款
INSERT INTO finance_records (id, shop_id, partner_id, type, amount, balance, ref_type, ref_id,
INSERT IGNORE 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 6 DAY), '君悦大酒店4月供货应收款', NOW(), NOW()),
@@ -294,3 +270,5 @@ INSERT INTO finance_records (id, shop_id, partner_id, type, amount, balance, ref
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());
SET FOREIGN_KEY_CHECKS = 1;
+44 -56
View File
@@ -1,83 +1,71 @@
-- =============================================================
-- 测试门店 S002 种子数据(空库存版)
-- 用法: sh scripts/dev.sh seed S002
-- 说明: 基础数据与 S001 相同,入库/出库/库存数据为空,模拟新门店初始状态
-- 说明: 仅写入数据,不清空。加 --clear 参数先清空再写入
-- 模拟新门店初始状态,无历史入库/出库/库存数据
-- =============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ── 清空(子表先清)────────────────────────────────────────
TRUNCATE TABLE inventory_check_items;
TRUNCATE TABLE inventory_checks;
TRUNCATE TABLE inventory_logs;
TRUNCATE TABLE inventories;
TRUNCATE TABLE stock_out_items;
TRUNCATE TABLE stock_out_orders;
TRUNCATE TABLE stock_in_items;
TRUNCATE TABLE stock_in_orders;
TRUNCATE TABLE finance_records;
TRUNCATE TABLE number_rules;
TRUNCATE TABLE product_images;
TRUNCATE TABLE partners;
TRUNCATE TABLE warehouses;
TRUNCATE TABLE products;
TRUNCATE TABLE product_categories;
TRUNCATE TABLE users;
TRUNCATE TABLE shops;
SET FOREIGN_KEY_CHECKS = 1;
-- ── 门店 ────────────────────────────────────────────────────
INSERT INTO shops (id, name, code, address, phone, manager_name, created_at, updated_at)
VALUES (1, '醇香汇酒业', 'S002', '上海市静安区南京西路1288号恒隆广场L1-06', '021-52088899', '李文博', NOW(), NOW());
INSERT IGNORE INTO shops (name, code, address, phone, manager_name, created_at, updated_at)
VALUES ('醇香汇酒业', 'S002', '上海市静安区南京西路1288号恒隆广场L1-06', '021-52088899', '李文博', NOW(), NOW());
SET @shop_id = (SELECT id FROM shops WHERE code = 'S002' LIMIT 1);
-- ── 用户(密码均为 password123)────────────────────────────
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
INSERT INTO users (id, shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at) VALUES
(1, 1, 'admin', @pwd, '张三(管理员)', '13800000001', 'admin', 1, NOW(), NOW()),
(2, 1, 'operator', @pwd, '四(操作员)', '13800000002', 'operator', 1, NOW(), NOW()),
(3, 1, 'test', @pwd, '王五(只读', '13800000003', 'readonly', 1, NOW(), NOW());
INSERT INTO users (shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at)
VALUES
(@shop_id, 'admin', @pwd, '文博(管理员)', '13900000001', 'admin', 1, NOW(), NOW()),
(@shop_id, 'operator', @pwd, '陈小红(操作员', '13900000002', 'operator', 1, NOW(), NOW()),
(@shop_id, 'test', @pwd, '王明(只读)', '13900000003', 'readonly', 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE password_hash = @pwd, is_active = 1, updated_at = NOW();
-- ── 仓库 ────────────────────────────────────────────────────
INSERT INTO warehouses (id, shop_id, name, location, is_default, created_at, updated_at) VALUES
(1, 1, '主仓库', 'A1层东侧', 1, NOW(), NOW()),
(2, 1, '进口酒专库', 'B栋2层恒温', 0, NOW(), NOW());
INSERT IGNORE INTO warehouses (shop_id, name, location, is_default, created_at, updated_at) VALUES
(@shop_id, '主仓库', 'A1层', 1, NOW(), NOW()),
(@shop_id, '进口酒专库', 'B区恒温展示', 0, NOW(), NOW());
-- ── 商品分类 ────────────────────────────────────────────────
INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updated_at) VALUES
(1, 1, '白酒', 1, NOW(), NOW()),
(2, 1, '进口烈酒', 2, NOW(), NOW());
INSERT IGNORE INTO product_categories (shop_id, name, sort_order, created_at, updated_at) VALUES
(@shop_id, '白酒', 1, NOW(), NOW()),
(@shop_id, '进口烈酒', 2, NOW(), NOW());
-- ── 商品 ────────────────────────────────────────────────────
INSERT INTO products (id, shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
SET @cat_baijiu = (SELECT id FROM product_categories WHERE shop_id = @shop_id AND name = '白酒' LIMIT 1);
SET @cat_import = (SELECT id FROM product_categories WHERE shop_id = @shop_id AND name = '进口烈酒' LIMIT 1);
INSERT IGNORE INTO products (shop_id, public_id, code, barcode, name, series, spec, unit, category_id, brand,
purchase_price, sale_price, min_stock, remark, created_at, updated_at) VALUES
(1, 1, 'b2c3d4e5-0002-0002-0002-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度 500ml', '茅台', '500ml/瓶', '', 1, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
(2, 1, 'b2c3d4e5-0002-0002-0002-000000000002', 'WLY-001', '6902345678901', '五粮液 52度 500ml', '五粮液', '500ml/瓶', '', 1, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
(3, 1, 'b2c3d4e5-0002-0002-0002-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6 500ml', '洋河', '500ml/瓶', '', 1, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
(4, 1, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
(5, 1, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
(6, 1, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
(7, 1, 'b2c3d4e5-0002-0002-0002-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018 750ml', '波尔多', '750ml/瓶', '', 2, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
(8, 1, 'b2c3d4e5-0002-0002-0002-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP 700ml', '人头马', '700ml/瓶', '', 2, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '', @cat_baijiu, '贵州茅台', 2350, 2800, 10, '酱香型白酒,53度,飞天系列', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '', @cat_baijiu, '宜宾五粮液', 950, 1200, 6, '浓香型白酒,52度,普五系列', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6', '洋河', '500ml/瓶', '', @cat_baijiu, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲', '泸州老窖', '500ml/瓶', '', @cat_baijiu, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑', '剑南春', '500ml/瓶', '', @cat_baijiu, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10', '郎酒', '500ml/瓶', '', @cat_baijiu, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018', '波尔多', '750ml/瓶', '', @cat_import, 'Château Lafite', 3800, 5200, 3, '波尔多一级名庄,2018年份', NOW(), NOW()),
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000008', 'RTM-001', '3021691010008', '人头马 VSOP', '人头马', '700ml/瓶', '', @cat_import, 'Rémy Martin', 480, 680, 3, '法国干邑,VSOP级别', NOW(), NOW());
-- ── 往来单位 ────────────────────────────────────────────────
INSERT INTO partners (id, shop_id, code, name, type, contact, phone, address, bank_account,
INSERT IGNORE INTO partners (shop_id, code, name, type, contact, phone, address, bank_account,
credit_limit, balance, remark, created_at, updated_at) VALUES
(1, 1, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
(2, 1, 'SUP002', '四川五粮液股份有限公司', 'supplier', '王总监', '0831-33333001', '四川省宜宾市翠屏区', '建设银行宜宾支行 6227 0000 0001 0002', 3000000, 0, '五粮液系列授权经销', NOW(), NOW()),
(3, 1, 'SUP003', '江苏洋河酒厂股份有限公司', 'supplier', '赵主任', '0527-44444001', '江苏省宿迁市洋河新区', '农业银行宿迁支行 6228 0000 0001 0003', 2000000, 0, '洋河梦之蓝系列直供', NOW(), NOW()),
(4, 1, 'SUP004', '四川郎酒股份有限公司', 'supplier', '刘经理', '0830-55555001', '四川省古蔺县二郎镇', '中国银行古蔺支行 6013 0000 0001 0004', 1500000, 0, '郎酒红花郎系列', NOW(), NOW()),
(5, 1, 'CUS001', '北京君悦大酒店', 'customer', '采购', '010-65888001', '北京市朝阳区建国门外大街2号', '中信银行北京朝阳支行 6217 0000 0002 0001', 500000, 0, '五星级酒店,月结', NOW(), NOW()),
(6, 1, 'CUS002', '上海外滩华尔道夫酒店', 'customer', '陈主任', '021-62308001', '上海市黄浦区中山东一路2', '浦发银行上海黄浦支行 6210 0000 0002 0002', 800000, 0, '豪华酒店,季结', NOW(), NOW()),
(7, 1, 'CUS003', '广州白天鹅宾馆', 'customer', '吴采购', '020-81886001', '广州市荔湾区沙面南街1号', '招商银行广州荔湾支行 6225 0000 0002 0003', 300000, 0, '高端宾馆,月结30天', NOW(), NOW());
(@shop_id, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
(@shop_id, 'SUP002', '四川五粮液股份有限公司', 'supplier', '王总监', '0831-33333001', '四川省宜宾市翠屏区', '建设银行宜宾支行 6227 0000 0001 0002', 3000000, 0, '五粮液系列授权经销', NOW(), NOW()),
(@shop_id, 'SUP003', '江苏洋河酒厂股份有限公司', 'supplier', '赵主任', '0527-44444001', '江苏省宿迁市洋河新区', '农业银行宿迁支行 6228 0000 0001 0003', 2000000, 0, '洋河梦之蓝系列直供', NOW(), NOW()),
(@shop_id, 'SUP004', '四川郎酒股份有限公司', 'supplier', '刘经理', '0830-55555001', '四川省古蔺县二郎镇', '中国银行古蔺支行 6013 0000 0001 0004', 1500000, 0, '郎酒红花郎系列', NOW(), NOW()),
(@shop_id, 'CUS001', '上海外滩五号餐厅', 'customer', '采购', '021-63869001', '上海市黄浦区外滩5号', '浦发银行上海黄浦支行 6210 0000 0003 0001', 300000, 0, '高端餐厅,月结', NOW(), NOW()),
(@shop_id, 'CUS002', '苏州虎丘山庄度假酒店', 'customer', '周总', '0512-66888001', '江苏省苏州市虎丘区虎丘路168', '工商银行苏州支行 6222 0000 0003 0002', 200000, 0, '度假酒店,季结', NOW(), NOW());
-- ── 编号规则(序号从 0 开始,无历史单据)──────────────────
INSERT INTO number_rules (id, shop_id, type, prefix, current_no, date_format, updated_at) VALUES
(1, 1, 'stock_in', 'RK', 0, 'YYYYMMDD', NOW()),
(2, 1, 'stock_out', 'CK', 0, 'YYYYMMDD', NOW()),
(3, 1, 'inventory_check', 'PD', 0, 'YYYYMMDD', NOW());
INSERT IGNORE INTO number_rules (shop_id, type, prefix, current_no, date_format, updated_at) VALUES
(@shop_id, 'stock_in', 'RK', 0, 'YYYYMMDD', NOW()),
(@shop_id, 'stock_out', 'CK', 0, 'YYYYMMDD', NOW()),
(@shop_id, 'inventory_check', 'PD', 0, 'YYYYMMDD', NOW());
-- ── 入库单/出库单/库存/流水/财务:均为空 ──────────────────
-- 模拟新门店初始状态,无历史数据
-- 模拟新门店初始状态,无历史数据
SET FOREIGN_KEY_CHECKS = 1;
+32
View File
@@ -0,0 +1,32 @@
-- clear_shop.sql — 清空指定门店的所有业务数据
-- 用法(调用方负责传入 @shop_code 变量):
-- SET @shop_code = 'S001'; SOURCE clear_shop.sql;
-- 或由脚本拼接:
-- echo "SET @shop_code='S001';" | cat - clear_shop.sql | mysql ...
SET @sid = (SELECT id FROM shops WHERE BINARY code = BINARY @shop_code LIMIT 1);
SET FOREIGN_KEY_CHECKS = 0;
DELETE FROM inventory_check_items WHERE shop_id = @sid;
DELETE FROM inventory_checks WHERE shop_id = @sid;
DELETE FROM inventory_logs WHERE shop_id = @sid;
DELETE FROM inventories WHERE shop_id = @sid;
DELETE FROM stock_out_items WHERE shop_id = @sid;
DELETE FROM stock_out_orders WHERE shop_id = @sid;
DELETE FROM stock_in_items WHERE shop_id = @sid;
DELETE FROM stock_in_orders WHERE shop_id = @sid;
DELETE FROM finance_records WHERE shop_id = @sid;
DELETE FROM number_rules WHERE shop_id = @sid;
DELETE FROM product_images WHERE shop_id = @sid;
DELETE FROM partners WHERE shop_id = @sid;
DELETE FROM warehouses WHERE shop_id = @sid;
DELETE FROM product_spec_options WHERE shop_id = @sid;
DELETE FROM product_series_options WHERE shop_id = @sid;
DELETE FROM product_name_options WHERE shop_id = @sid;
DELETE FROM products WHERE shop_id = @sid;
DELETE FROM product_categories WHERE shop_id = @sid;
DELETE FROM users WHERE shop_id = @sid;
DELETE FROM shops WHERE id = @sid;
SET FOREIGN_KEY_CHECKS = 1;
+1 -4
View File
@@ -349,10 +349,7 @@ func hashPassword(plain string) string {
func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User {
hash := hashPassword(password)
user := &model.User{
TenantBase: model.TenantBase{
Base: model.Base{},
ShopID: shopID,
},
ShopID: shopID,
Username: username,
PasswordHash: hash,
RealName: "Test User " + username,
@@ -99,42 +99,94 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final file = result.files.first;
final bytes = file.bytes;
if (bytes == null) return;
if (!context.mounted) return;
final token = ref.read(authStateProvider).user?.accessToken ?? '';
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 300),
));
dio.options.headers['Authorization'] = 'Bearer $token';
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
final stateNotifier = ValueNotifier<_ImportState>(
const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0),
);
BuildContext? dialogCtx;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
dialogCtx = ctx;
return PopScope(
canPop: false,
child: _ImportProgressDialog(stateNotifier: stateNotifier),
);
},
);
Timer? processingTimer;
bool uploadDone = false;
int elapsed = 0;
void closeDialog() {
processingTimer?.cancel();
if (dialogCtx != null && dialogCtx!.mounted) {
Navigator.of(dialogCtx!).pop();
}
}
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: file.name),
});
final resp = await dio.post('/import/inventory', data: formData);
final data = resp.data as Map<String, dynamic>;
final imported = data['imported'] ?? 0;
final skipped = data['skipped'] ?? 0;
final errors = (data['errors'] as List?)?.cast<String>() ?? [];
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? '${errors.length} 条失败' : ''}'),
backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent,
duration: const Duration(seconds: 4),
));
ref.read(inventoryListProvider.notifier).reload();
final resp = await dio.post(
'/import/inventory',
data: formData,
onSendProgress: (sent, total) {
if (total <= 0) return;
if (sent >= total && !uploadDone) {
uploadDone = true;
elapsed = 0;
stateNotifier.value = const _ImportState(
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: 0,
);
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
elapsed++;
stateNotifier.value = _ImportState(
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: elapsed,
);
});
} else if (!uploadDone) {
final pct = (sent / total * 100).round().clamp(0, 99);
stateNotifier.value = _ImportState(stage: _ImportStage.uploading, uploadPercent: pct);
}
},
);
processingTimer?.cancel();
final data = resp.data as Map<String, dynamic>;
stateNotifier.value = _ImportState(
stage: _ImportStage.done,
uploadPercent: 100,
imported: data['imported'] ?? 0,
updated: data['updated'] ?? 0,
skipped: data['skipped'] ?? 0,
errors: (data['errors'] as List?)?.cast<String>() ?? [],
);
await Future.delayed(const Duration(seconds: 2));
closeDialog();
if (context.mounted) ref.read(inventoryListProvider.notifier).reload();
} on DioException catch (e) {
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误';
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入失败:$msg'),
backgroundColor: AppTheme.danger,
));
processingTimer?.cancel();
final msg = (e.response?.data is Map ? e.response!.data['error'] : null)
?? e.message ?? '未知错误';
stateNotifier.value = _ImportState(
stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg,
);
}
}
@@ -865,3 +917,149 @@ class _DirectionBadge extends StatelessWidget {
);
}
}
// ── 导入进度对话框 ──────────────────────────────────────────
enum _ImportStage { uploading, processing, done, error }
class _ImportState {
final _ImportStage stage;
final int uploadPercent;
final int processingSeconds;
final int imported;
final int updated;
final int skipped;
final List<String> errors;
final String? errorMsg;
const _ImportState({
required this.stage,
required this.uploadPercent,
this.processingSeconds = 0,
this.imported = 0,
this.updated = 0,
this.skipped = 0,
this.errors = const [],
this.errorMsg,
});
}
class _ImportProgressDialog extends StatelessWidget {
final ValueNotifier<_ImportState> stateNotifier;
const _ImportProgressDialog({required this.stateNotifier});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('导入库存'),
content: ValueListenableBuilder<_ImportState>(
valueListenable: stateNotifier,
builder: (_, state, __) => SizedBox(
width: 320,
child: _buildContent(context, state),
),
),
actions: [
ValueListenableBuilder<_ImportState>(
valueListenable: stateNotifier,
builder: (ctx, state, __) {
if (state.stage == _ImportStage.done || state.stage == _ImportStage.error) {
return TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('关闭'),
);
}
return const SizedBox.shrink();
},
),
],
);
}
Widget _buildContent(BuildContext context, _ImportState state) {
switch (state.stage) {
case _ImportStage.uploading:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('上传文件...', style: TextStyle(fontSize: 14)),
const SizedBox(height: 12),
LinearProgressIndicator(
value: state.uploadPercent / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
const SizedBox(height: 6),
Text('${state.uploadPercent}%',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
case _ImportStage.processing:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
),
const SizedBox(width: 10),
Text(
'导入数据中...${state.processingSeconds > 0 ? "${state.processingSeconds}秒)" : ""}',
style: const TextStyle(fontSize: 14),
),
]),
const SizedBox(height: 8),
LinearProgressIndicator(
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
),
],
);
case _ImportStage.done:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
const SizedBox(width: 8),
const Text('导入成功', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
]),
const SizedBox(height: 10),
Text('新增:${state.imported}', style: const TextStyle(fontSize: 13)),
if (state.updated > 0)
Text('更新:${state.updated}', style: const TextStyle(fontSize: 13)),
if (state.skipped > 0)
Text('跳过:${state.skipped}',
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
if (state.errors.isNotEmpty)
Text('失败:${state.errors.length}',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
],
);
case _ImportStage.error:
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, color: AppTheme.danger, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'导入失败:${state.errorMsg}',
style: TextStyle(fontSize: 13, color: AppTheme.danger),
),
),
],
);
}
}
}
@@ -1,3 +1,4 @@
import 'dart:async';
import '../../core/utils/dialog_util.dart';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
@@ -1524,10 +1525,22 @@ class _ImportSlot {
int total = 0;
int imported = 0;
int skipped = 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 {
@@ -1657,14 +1670,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
setState(() {
_loading = true;
for (final s in _slots) {
if (s.file != null) {
s.success = null;
s.error = null;
}
if (s.file != null) s.resetProgress();
}
});
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
final dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
sendTimeout: const Duration(seconds: 120),
receiveTimeout: const Duration(seconds: 300),
));
dio.options.headers['Authorization'] = 'Bearer $token';
for (final slot in _slots) {
@@ -1674,29 +1688,56 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
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);
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;
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
slot.imported = (data['imported'] ?? 0) as int;
slot.skipped = (data['skipped'] ?? 0) 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;
});
}
}
@@ -2047,10 +2088,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
);
}
} else if (_loading && slot.file != null) {
statusWidget = const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
);
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(
+10
View File
@@ -7,6 +7,8 @@ server {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 20m;
# 商品图片静态文件
location ^~ /images/ {
alias /opt/jiu/images/;
@@ -14,6 +16,14 @@ server {
add_header Cache-Control "public, immutable";
}
# 文件导入接口(超时更长)
location ~ ^/api/v1/import/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
}
# API 反向代理
location ~ ^/(api|health|version) {
proxy_pass http://127.0.0.1:8080;
+28 -6
View File
@@ -36,12 +36,22 @@ WEB_PID_FILE="$LOG_DIR/web.pid"
mkdir -p "$LOG_DIR"
# ── seed 命令 ────────────────────────────────────────────────
# 用法: cmd_seed <shop_code>
# 从 backend/config/config.yaml 读取 DSN,执行 backend/seeds/<shop>.sql
# 用法: cmd_seed [--clear] <shop_code>
# 从 docker-compose.yml 读取容器信息,执行 backend/seeds/<shop>.sql
# --clear: 写入前先清空该门店所有数据
cmd_seed() {
local shop="${1:-}"
local clear=false
local shop=""
while [ $# -gt 0 ]; do
case "$1" in
--clear) clear=true ;;
*) shop="$1" ;;
esac
shift
done
if [ -z "$shop" ]; then
error "用法: sh scripts/dev.sh seed <shop_code> 示例: sh scripts/dev.sh seed H001"
error "用法: sh scripts/dev.sh seed [--clear] <shop_code> 示例: sh scripts/dev.sh seed S001"
exit 1
fi
@@ -64,6 +74,17 @@ cmd_seed() {
exit 1
fi
if [ "$clear" = "true" ]; then
local clear_file="$ROOT/backend/seeds/clear_shop.sql"
if [ ! -f "$clear_file" ]; then
error "清空脚本不存在: $clear_file"
exit 1
fi
info "清空 ${shop} 数据 → 容器 ${container} / ${db_name}"
{ printf "SET @shop_code='%s';\n" "$shop"; cat "$clear_file"; } \
| docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name"
fi
info "写入 ${shop} 种子数据 → 容器 ${container} / ${db_name}"
docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name" < "$sql_file"
success "${shop} 种子数据写入完成"
@@ -107,7 +128,8 @@ show_help() {
echo " --web-only 仅启动前端(Web"
echo ""
echo "数据库命令:"
echo " seed <shop_code> 清空并写入指定门店测试数据(如 H001"
echo " seed <shop_code> 写入指定门店测试数据(如 S001"
echo " seed --clear <shop> 先清空再写入(单店)"
echo " reset 删表重建(AutoMigrate"
echo " clear 清空所有业务数据"
echo ""
@@ -135,7 +157,7 @@ case "$COMMAND" in
esac
;;
seed)
cmd_seed "${2:-}"
cmd_seed "${2:-}" "${3:-}"
exit 0
;;
reset)