Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbb4f90ebd | |||
| a05f9bd4ec | |||
| 831dbc5959 | |||
| d5dc499c6b | |||
| 5725e84971 | |||
| 86baaf3a1c | |||
| fcf92675b1 | |||
| d81b2d1fac | |||
| acf2b22201 | |||
| 8adb94a9a4 |
@@ -46,7 +46,8 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "✓ Health check passed"
|
echo "✓ Health check passed"
|
||||||
|
|
||||||
# 4. 写入 seed 数据
|
# 4. 写入 seed 数据(第一个 shop 全量,后续跳过 TRUNCATE 行避免互相清空)
|
||||||
|
FIRST=true
|
||||||
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
|
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
|
||||||
FILE="backend/seeds/${SHOP}.sql"
|
FILE="backend/seeds/${SHOP}.sql"
|
||||||
if [ ! -f "$FILE" ]; then
|
if [ ! -f "$FILE" ]; then
|
||||||
@@ -54,9 +55,16 @@ jobs:
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
echo "→ Seeding $SHOP ..."
|
echo "→ Seeding $SHOP ..."
|
||||||
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
|
if [ "$FIRST" = "true" ]; then
|
||||||
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} jiu_db" \
|
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
|
||||||
< "$FILE"
|
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} --default-character-set=utf8mb4 jiu_db" \
|
||||||
|
< "$FILE"
|
||||||
|
FIRST=false
|
||||||
|
else
|
||||||
|
grep -v "^TRUNCATE" "$FILE" | \
|
||||||
|
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"
|
||||||
|
fi
|
||||||
echo "✓ $SHOP done"
|
echo "✓ $SHOP done"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
description: '要写入的门店(逗号分隔,如 S001,S002,S003)'
|
description: '要写入的门店(逗号分隔,如 S001,S002,S003)'
|
||||||
required: true
|
required: true
|
||||||
default: 'S001,S002,S003'
|
default: 'S001,S002,S003'
|
||||||
|
clear:
|
||||||
|
description: '写入前先清空各门店数据(true/false)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
seed:
|
seed:
|
||||||
@@ -27,7 +31,23 @@ jobs:
|
|||||||
EC2_USER: ${{ secrets.EC2_USER }}
|
EC2_USER: ${{ secrets.EC2_USER }}
|
||||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||||
SHOPS: ${{ github.event.inputs.shops }}
|
SHOPS: ${{ github.event.inputs.shops }}
|
||||||
|
CLEAR: ${{ github.event.inputs.clear }}
|
||||||
run: |
|
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
|
for SHOP in $(echo "$SHOPS" | tr ',' ' '); do
|
||||||
FILE="backend/seeds/${SHOP}.sql"
|
FILE="backend/seeds/${SHOP}.sql"
|
||||||
if [ ! -f "$FILE" ]; then
|
if [ ! -f "$FILE" ]; then
|
||||||
@@ -36,7 +56,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "→ Seeding $SHOP ..."
|
echo "→ Seeding $SHOP ..."
|
||||||
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
|
ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} \
|
||||||
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} jiu_db" \
|
"docker exec -i jiu_mysql mysql -uroot -p${DB_PASSWORD} --default-character-set=utf8mb4 jiu_db" \
|
||||||
< "$FILE"
|
< "$FILE"
|
||||||
echo "✓ $SHOP done"
|
echo "✓ $SHOP done"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -132,21 +132,21 @@ func main() {
|
|||||||
hash := mustHash("password123")
|
hash := mustHash("password123")
|
||||||
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
|
admin := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "admin", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
ShopID: shop.ID,
|
||||||
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
|
Username: "admin", PasswordHash: hash, RealName: "张三(管理员)",
|
||||||
Phone: "13800000001", Role: "admin", IsActive: true,
|
Phone: "13800000001", Role: "admin", IsActive: true,
|
||||||
}
|
}
|
||||||
}).(model.User)
|
}).(model.User)
|
||||||
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
|
operator := upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "operator", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
ShopID: shop.ID,
|
||||||
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
|
Username: "operator", PasswordHash: hash, RealName: "李四(操作员)",
|
||||||
Phone: "13800000002", Role: "operator", IsActive: true,
|
Phone: "13800000002", Role: "operator", IsActive: true,
|
||||||
}
|
}
|
||||||
}).(model.User)
|
}).(model.User)
|
||||||
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
|
upsert(db, &model.User{}, "shop_id = ? AND username = ?", shop.ID, "test", func() any {
|
||||||
return &model.User{
|
return &model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shop.ID},
|
ShopID: shop.ID,
|
||||||
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
|
Username: "test", PasswordHash: hash, RealName: "王五(只读)",
|
||||||
Phone: "13800000003", Role: "readonly", IsActive: true,
|
Phone: "13800000003", Role: "readonly", IsActive: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -512,66 +513,126 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type importResult struct {
|
type importResult struct {
|
||||||
|
total int
|
||||||
imported int
|
imported int
|
||||||
updated int
|
updated int
|
||||||
skipped int
|
|
||||||
errors []string
|
errors []string
|
||||||
}
|
}
|
||||||
var res importResult
|
var res importResult
|
||||||
|
|
||||||
// 仓库缓存,只查找不创建
|
// 预加载仓库(1 次查询)
|
||||||
warehouseCache := map[string]*uint64{}
|
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 {
|
findWarehouse := func(name string) *uint64 {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if idPtr, ok := warehouseCache[name]; ok {
|
if id, ok := warehouseByName[name]; ok {
|
||||||
return idPtr
|
return &id
|
||||||
}
|
}
|
||||||
var wh model.Warehouse
|
return nil
|
||||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
|
}
|
||||||
warehouseCache[name] = 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
|
productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p
|
||||||
warehouseCache[name] = &id
|
}
|
||||||
return &id
|
|
||||||
|
// 预加载已有导入库存(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)
|
||||||
|
invByCode := make(map[string]*model.Inventory, len(allInvs)) // key: productCode|warehouseID
|
||||||
|
invByNSS := make(map[string]*model.Inventory, len(allInvs)) // key: name|series|spec|warehouseID
|
||||||
|
for i := range allInvs {
|
||||||
|
inv := &allInvs[i]
|
||||||
|
whID := uint64(0)
|
||||||
|
if inv.WarehouseID != nil {
|
||||||
|
whID = *inv.WarehouseID
|
||||||
|
}
|
||||||
|
if inv.ProductCode != "" {
|
||||||
|
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
|
||||||
|
}
|
||||||
|
nssKey := fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whID)
|
||||||
|
invByNSS[nssKey] = inv
|
||||||
|
}
|
||||||
|
lookupInv := func(productCode, name, series, spec string, whID uint64) *model.Inventory {
|
||||||
|
if productCode != "" {
|
||||||
|
if inv, ok := invByCode[fmt.Sprintf("%s|%d", productCode, whID)]; ok {
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return invByNSS[fmt.Sprintf("%s|%s|%s|%d", name, series, spec, whID)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic column detection from header row
|
// Dynamic column detection from header row
|
||||||
|
colProductCode, colProductName, colSeries, colSpec, colUnit := 0, 1, 2, 3, 4
|
||||||
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
|
colQty, colPrice, colProductionDate, colBatchNo, colWarehouse, colSupplier, colRemark := 5, 6, 8, 9, 11, 13, 15
|
||||||
if len(rows) > 0 {
|
if len(rows) > 0 {
|
||||||
|
log.Printf("[import-inv] header row (%d cols): %v", len(rows[0]), rows[0])
|
||||||
for j, h := range rows[0] {
|
for j, h := range rows[0] {
|
||||||
switch strings.TrimSpace(h) {
|
switch strings.TrimSpace(h) {
|
||||||
case "库存数量", "数量":
|
case "商品编号", "商品编码", "编号", "编码", "商品条码":
|
||||||
|
colProductCode = j
|
||||||
|
case "商品名称", "品名", "名称", "货品名称":
|
||||||
|
colProductName = j
|
||||||
|
case "系列", "品牌系列":
|
||||||
|
colSeries = j
|
||||||
|
case "规格", "规格型号":
|
||||||
|
colSpec = j
|
||||||
|
case "单位":
|
||||||
|
colUnit = j
|
||||||
|
case "库存数量", "数量", "库存":
|
||||||
colQty = j
|
colQty = j
|
||||||
case "单价":
|
case "单价", "进价", "采购单价":
|
||||||
colPrice = j
|
colPrice = j
|
||||||
case "生产日期":
|
case "生产日期", "生产年月":
|
||||||
colProductionDate = j
|
colProductionDate = j
|
||||||
case "批次", "批次号":
|
case "批次", "批次号":
|
||||||
colBatchNo = j
|
colBatchNo = j
|
||||||
case "所在仓库", "仓库":
|
case "所在仓库", "仓库", "库位":
|
||||||
colWarehouse = j
|
colWarehouse = j
|
||||||
case "供应商":
|
case "供应商", "供应商名称":
|
||||||
colSupplier = j
|
colSupplier = j
|
||||||
case "备注":
|
case "备注":
|
||||||
colRemark = j
|
colRemark = j
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Printf("[import-inv] total rows=%d, colProductName=%d, colProductCode=%d, colQty=%d",
|
||||||
|
len(rows), colProductName, colProductCode, colQty)
|
||||||
|
|
||||||
|
// 如果没有匹配到任何列头,返回诊断信息
|
||||||
|
detectedHeader := strings.Join(rows[0], " | ")
|
||||||
|
|
||||||
|
var logsToCreate []model.InventoryLog
|
||||||
|
|
||||||
for i, row := range rows[1:] {
|
for i, row := range rows[1:] {
|
||||||
productName := cell(row, 1)
|
productName := cell(row, colProductName)
|
||||||
|
if i < 5 {
|
||||||
|
log.Printf("[import-inv] row[%d] len=%d | productName=%q productCode=%q qty=%q",
|
||||||
|
i+2, len(row), productName, cell(row, colProductCode), cell(row, colQty))
|
||||||
|
}
|
||||||
if productName == "" {
|
if productName == "" {
|
||||||
res.skipped++
|
continue // 空行(文件末尾填充行),不计入 total
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
productCode := cell(row, 0)
|
productCode := cell(row, colProductCode)
|
||||||
series := cell(row, 2)
|
series := cell(row, colSeries)
|
||||||
spec := cell(row, 3)
|
spec := cell(row, colSpec)
|
||||||
unit := cell(row, 4)
|
unit := cell(row, colUnit)
|
||||||
qtyStr := cell(row, colQty)
|
qtyStr := cell(row, colQty)
|
||||||
priceStr := cell(row, colPrice)
|
priceStr := cell(row, colPrice)
|
||||||
productionDateStr := cell(row, colProductionDate)
|
productionDateStr := cell(row, colProductionDate)
|
||||||
@@ -586,20 +647,32 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
price, _ := strconv.ParseFloat(priceStr, 64)
|
price, _ := strconv.ParseFloat(priceStr, 64)
|
||||||
|
|
||||||
// 只查找商品,不强制创建
|
res.total++ // 有商品名称的行才计入总数
|
||||||
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)
|
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
|
||||||
continue
|
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 == "" {
|
if unit != "" && prod.Unit == "" {
|
||||||
h.db.Model(&prod).Update("unit", unit)
|
h.db.Model(prod).Update("unit", unit)
|
||||||
|
prod.Unit = unit
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析生产日期
|
// 解析生产日期
|
||||||
@@ -609,7 +682,6 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
productionDate = &d
|
productionDate = &d
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找仓库(只查,不创建)
|
|
||||||
whIDPtr := findWarehouse(warehouseName)
|
whIDPtr := findWarehouse(warehouseName)
|
||||||
|
|
||||||
var unitPricePtr *float64
|
var unitPricePtr *float64
|
||||||
@@ -617,30 +689,24 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
unitPricePtr = &price
|
unitPricePtr = &price
|
||||||
}
|
}
|
||||||
|
|
||||||
productIDCopy := prod.ID
|
whIDVal := uint64(0)
|
||||||
|
|
||||||
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
|
|
||||||
var existing model.Inventory
|
|
||||||
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
|
|
||||||
shopID, prod.Code)
|
|
||||||
if whIDPtr != nil {
|
if whIDPtr != nil {
|
||||||
q = q.Where("warehouse_id = ?", *whIDPtr)
|
whIDVal = *whIDPtr
|
||||||
} else {
|
|
||||||
q = q.Where("warehouse_id IS NULL")
|
|
||||||
}
|
}
|
||||||
found := q.First(&existing).Error == nil
|
|
||||||
|
|
||||||
if found {
|
existing := lookupInv(productCode, prod.Name, prod.Series, prod.Spec, whIDVal)
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"quantity": qty,
|
"quantity": qty,
|
||||||
"product_name": prod.Name,
|
"product_name": prod.Name,
|
||||||
"series": prod.Series,
|
"series": prod.Series,
|
||||||
"spec": prod.Spec,
|
"spec": prod.Spec,
|
||||||
"unit": prod.Unit,
|
"unit": prod.Unit,
|
||||||
"warehouse_name": warehouseName,
|
"warehouse_name": warehouseName,
|
||||||
"supplier_name": supplierName,
|
"supplier_name": supplierName,
|
||||||
"remark": remark,
|
"remark": remark,
|
||||||
"deleted_at": nil,
|
"deleted_at": nil,
|
||||||
}
|
}
|
||||||
if unitPricePtr != nil {
|
if unitPricePtr != nil {
|
||||||
updates["unit_price"] = *unitPricePtr
|
updates["unit_price"] = *unitPricePtr
|
||||||
@@ -651,11 +717,18 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
if batchNo != "" {
|
if batchNo != "" {
|
||||||
updates["batch_no"] = 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()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
|
||||||
continue
|
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 {
|
} else {
|
||||||
|
productIDCopy := prod.ID
|
||||||
inv := model.Inventory{
|
inv := model.Inventory{
|
||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
WarehouseID: whIDPtr,
|
WarehouseID: whIDPtr,
|
||||||
@@ -678,38 +751,38 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
// 写入两套缓存,防止同文件后续行重复插入
|
||||||
|
if inv.ProductCode != "" {
|
||||||
// 写流水
|
invByCode[fmt.Sprintf("%s|%d", inv.ProductCode, whIDVal)] = &inv
|
||||||
warehouseID := uint64(0)
|
}
|
||||||
if whIDPtr != nil {
|
invByNSS[fmt.Sprintf("%s|%s|%s|%d", inv.ProductName, inv.Series, inv.Spec, whIDVal)] = &inv
|
||||||
warehouseID = *whIDPtr
|
logsToCreate = append(logsToCreate, model.InventoryLog{
|
||||||
}
|
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
|
||||||
qtyBefore := 0.0
|
Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty,
|
||||||
if found {
|
RefType: "import", RefID: 0,
|
||||||
qtyBefore = existing.Quantity
|
})
|
||||||
res.updated++
|
|
||||||
} else {
|
|
||||||
res.imported++
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// total=0 说明列格式不匹配,没有解析到任何有效行
|
||||||
|
if res.total == 0 {
|
||||||
|
res.errors = append(res.errors,
|
||||||
|
fmt.Sprintf("未解析到任何有效行,可能列格式不匹配。识别到的表头:%s", detectedHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[import-inv] RESULT: total=%d imported=%d updated=%d errors=%d",
|
||||||
|
res.total, res.imported, res.updated, len(res.errors))
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"total": res.total,
|
||||||
"imported": res.imported,
|
"imported": res.imported,
|
||||||
"updated": res.updated,
|
"updated": res.updated,
|
||||||
"skipped": res.skipped,
|
|
||||||
"errors": res.errors,
|
"errors": res.errors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/wangjia/jiu/backend/config"
|
||||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||||
"github.com/wangjia/jiu/backend/internal/model"
|
"github.com/wangjia/jiu/backend/internal/model"
|
||||||
)
|
)
|
||||||
@@ -38,6 +46,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
|||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
ManagerName string `json:"manager_name"`
|
ManagerName string `json:"manager_name"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -50,6 +59,9 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
|||||||
"phone": req.Phone,
|
"phone": req.Phone,
|
||||||
"manager_name": req.ManagerName,
|
"manager_name": req.ManagerName,
|
||||||
}
|
}
|
||||||
|
if req.LogoURL != "" {
|
||||||
|
updates["logo_url"] = req.LogoURL
|
||||||
|
}
|
||||||
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
|
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Updates(updates).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -59,3 +71,48 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
|
|||||||
h.db.First(&shop, shopID)
|
h.db.First(&shop, shopID)
|
||||||
c.JSON(http.StatusOK, shop)
|
c.JSON(http.StatusOK, shop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadLogo POST /api/v1/shop/logo (admin only)
|
||||||
|
func (h *ShopHandler) UploadLogo(c *gin.Context) {
|
||||||
|
shopID := middleware.GetShopID(c)
|
||||||
|
|
||||||
|
if err := c.Request.ParseMultipartForm(2 << 20); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 2MB 限制"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 裁剪为正方形后缩放到 256×256
|
||||||
|
resized := imaging.Fill(img, 256, 256, imaging.Center, imaging.Lanczos)
|
||||||
|
|
||||||
|
subdir := filepath.Join(config.C.Storage.UploadDir, "shops", fmt.Sprintf("%d", shopID))
|
||||||
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(subdir, "logo.jpg")
|
||||||
|
|
||||||
|
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(90)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logoURL := fmt.Sprintf("/images/shops/%d/logo.jpg", shopID)
|
||||||
|
if err := h.db.Model(&model.Shop{}).Where("id = ?", shopID).Update("logo_url", logoURL).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"logo_url": logoURL})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (h *UserHandler) Create(c *gin.Context) {
|
|||||||
role = "operator"
|
role = "operator"
|
||||||
}
|
}
|
||||||
u := model.User{
|
u := model.User{
|
||||||
TenantBase: model.TenantBase{ShopID: shopID},
|
ShopID: shopID,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
RealName: req.RealName,
|
RealName: req.RealName,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Shop struct {
|
|||||||
Phone string `gorm:"size:30" json:"phone"`
|
Phone string `gorm:"size:30" json:"phone"`
|
||||||
BusinessHours string `gorm:"size:100" json:"business_hours"`
|
BusinessHours string `gorm:"size:100" json:"business_hours"`
|
||||||
ManagerName string `gorm:"size:50" json:"manager_name"`
|
ManagerName string `gorm:"size:50" json:"manager_name"`
|
||||||
|
LogoURL string `gorm:"column:logo_url;size:500" json:"logo_url"`
|
||||||
BusinessLicense string `gorm:"size:500" json:"business_license"`
|
BusinessLicense string `gorm:"size:500" json:"business_license"`
|
||||||
ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"`
|
ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"`
|
||||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
type User struct {
|
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"`
|
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
|
||||||
PasswordHash string `gorm:"size:255" json:"-"`
|
PasswordHash string `gorm:"size:255" json:"-"`
|
||||||
RealName string `gorm:"size:50" json:"real_name"`
|
RealName string `gorm:"size:50" json:"real_name"`
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
{
|
{
|
||||||
shop.GET("/info", shopH.GetInfo)
|
shop.GET("/info", shopH.GetInfo)
|
||||||
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
|
shop.PUT("/info", middleware.AdminOnly(), shopH.UpdateInfo)
|
||||||
|
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编号规则
|
// 编号规则
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `shops` (
|
|||||||
`address` VARCHAR(255) DEFAULT NULL,
|
`address` VARCHAR(255) DEFAULT NULL,
|
||||||
`phone` VARCHAR(30) DEFAULT NULL,
|
`phone` VARCHAR(30) DEFAULT NULL,
|
||||||
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
|
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '负责人',
|
||||||
|
`logo_url` VARCHAR(500) DEFAULT '' COMMENT '门店 logo URL',
|
||||||
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
|
`business_license` VARCHAR(500) DEFAULT NULL COMMENT '营业执照照片URL',
|
||||||
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
|
`shop_photos` JSON DEFAULT NULL COMMENT '门店照片URL数组',
|
||||||
`custom_fields` JSON DEFAULT NULL COMMENT '扩展字段',
|
`custom_fields` JSON DEFAULT NULL COMMENT '扩展字段',
|
||||||
|
|||||||
+38
-60
@@ -2,39 +2,15 @@
|
|||||||
-- 测试门店 S001 种子数据
|
-- 测试门店 S001 种子数据
|
||||||
-- 用法: sh scripts/dev.sh seed S001
|
-- 用法: sh scripts/dev.sh seed S001
|
||||||
-- 前置条件: 数据库表结构已创建(启动一次后端即可)
|
-- 前置条件: 数据库表结构已创建(启动一次后端即可)
|
||||||
-- 说明: 每次执行会清空并重新写入所有业务数据
|
-- 说明: 仅写入数据,不清空。加 --clear 参数先清空再写入
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
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
|
-- 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());
|
VALUES (1, '盛世名酿酒行', 'S001', '北京市朝阳区建国路88号华贸中心B座101室', '010-65882266', '张建国', NOW(), NOW());
|
||||||
|
|
||||||
-- ── 用户(密码均为 password123)────────────────────────────
|
-- ── 用户(密码均为 password123)────────────────────────────
|
||||||
@@ -42,26 +18,26 @@ VALUES (1, '盛世名酿酒行', 'S001', '北京市朝阳区建国路88号华贸
|
|||||||
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
|
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
|
||||||
|
|
||||||
-- id=1 admin, id=2 operator, id=3 readonly
|
-- 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()),
|
(1, 1, 'admin', @pwd, '张三(管理员)', '13800000001', 'admin', 1, NOW(), NOW()),
|
||||||
(2, 1, 'operator', @pwd, '李四(操作员)', '13800000002', 'operator', 1, NOW(), NOW()),
|
(2, 1, 'operator', @pwd, '李四(操作员)', '13800000002', 'operator', 1, NOW(), NOW()),
|
||||||
(3, 1, 'test', @pwd, '王五(只读)', '13800000003', 'readonly', 1, NOW(), NOW());
|
(3, 1, 'test', @pwd, '王五(只读)', '13800000003', 'readonly', 1, NOW(), NOW());
|
||||||
|
|
||||||
-- ── 仓库 ────────────────────────────────────────────────────
|
-- ── 仓库 ────────────────────────────────────────────────────
|
||||||
-- id=1 主仓库, id=2 进口酒专库
|
-- 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()),
|
(1, 1, '主仓库', 'A栋1层东侧', 1, NOW(), NOW()),
|
||||||
(2, 1, '进口酒专库', 'B栋2层恒温区', 0, NOW(), NOW());
|
(2, 1, '进口酒专库', 'B栋2层恒温区', 0, NOW(), NOW());
|
||||||
|
|
||||||
-- ── 商品分类 ────────────────────────────────────────────────
|
-- ── 商品分类 ────────────────────────────────────────────────
|
||||||
-- id=1 白酒, id=2 进口烈酒
|
-- 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()),
|
(1, 1, '白酒', 1, NOW(), NOW()),
|
||||||
(2, 1, '进口烈酒', 2, NOW(), NOW());
|
(2, 1, '进口烈酒', 2, NOW(), NOW());
|
||||||
|
|
||||||
-- ── 商品 ────────────────────────────────────────────────────
|
-- ── 商品 ────────────────────────────────────────────────────
|
||||||
-- id=1~6 白酒, id=7~8 进口烈酒
|
-- 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
|
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()),
|
(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()),
|
(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());
|
(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, 'NA001', '飞天茅台 53度', NOW(), NOW()),
|
||||||
(1, 'NA002', '五粮液 52度', NOW(), NOW()),
|
(1, 'NA002', '五粮液 52度', NOW(), NOW()),
|
||||||
(1, 'NA003', '洋河梦之蓝 M6', 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());
|
(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, 'SE001', '茅台', NOW(), NOW()),
|
||||||
(1, 'SE002', '五粮液', NOW(), NOW()),
|
(1, 'SE002', '五粮液', NOW(), NOW()),
|
||||||
(1, 'SE003', '洋河', 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());
|
(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, 'GG001', '500ml/瓶', 1, NOW(), NOW()),
|
||||||
(1, 'GG002', '750ml/瓶', 1, NOW(), NOW()),
|
(1, 'GG002', '750ml/瓶', 1, NOW(), NOW()),
|
||||||
(1, 'GG003', '700ml/瓶', 1, NOW(), NOW());
|
(1, 'GG003', '700ml/瓶', 1, NOW(), NOW());
|
||||||
|
|
||||||
-- ── 往来单位 ────────────────────────────────────────────────
|
-- ── 往来单位 ────────────────────────────────────────────────
|
||||||
-- id=1~4 供应商, id=5~7 客户
|
-- 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
|
credit_limit, balance, remark, created_at, updated_at) VALUES
|
||||||
(1, 1, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
|
(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()),
|
(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());
|
(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()),
|
(1, 1, 'stock_in', 'RK', 5, 'YYYYMMDD', NOW()),
|
||||||
(2, 1, 'stock_out', 'CK', 4, 'YYYYMMDD', NOW()),
|
(2, 1, 'stock_out', 'CK', 4, 'YYYYMMDD', NOW()),
|
||||||
(3, 1, 'inventory_check', 'PD', 1, 'YYYYMMDD', NOW());
|
(3, 1, 'inventory_check', 'PD', 1, 'YYYYMMDD', NOW());
|
||||||
|
|
||||||
-- ── 入库单 ──────────────────────────────────────────────────
|
-- ── 入库单 ──────────────────────────────────────────────────
|
||||||
-- #1 已审核: 飞天茅台+五粮液,主仓库,茅台供应商 | 总额=339000
|
-- #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
|
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
|
||||||
(1, 1, 'RK20260401001', 'purchase', 1, 1, 1,
|
(1, 1, 'RK20260401001', 'purchase', 1, 1, 1,
|
||||||
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 6 DAY), 339000,
|
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 6 DAY), 339000,
|
||||||
DATE_SUB(NOW(), INTERVAL 5 DAY), '茅台系列首批入库,含飞天茅台及五粮液', NOW(), NOW());
|
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
|
batch_no, remark, created_at, updated_at) VALUES
|
||||||
(1, 1, 1, 1, 120, 2350, 282000, 'BATCH-MT-2024001', '飞天茅台 2024年第一批次', NOW(), NOW()),
|
(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, 1, 1, 2, 60, 950, 57000, 'BATCH-WLY-2024001', '五粮液普五 2024年批次', NOW(), NOW());
|
||||||
|
|
||||||
-- #2 已审核: 洋河+泸州+剑南春,主仓库,五粮液供应商 | 总额=51360
|
-- #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
|
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
|
||||||
(2, 1, 'RK20260403001', 'purchase', 1, 2, 2,
|
(2, 1, 'RK20260403001', 'purchase', 1, 2, 2,
|
||||||
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 4 DAY), 51360,
|
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 4 DAY), 51360,
|
||||||
DATE_SUB(NOW(), INTERVAL 3 DAY), '浓香型白酒补货入库', NOW(), NOW());
|
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
|
batch_no, remark, created_at, updated_at) VALUES
|
||||||
(3, 2, 1, 3, 48, 560, 26880, 'BATCH-YH-2024003', '洋河梦之蓝 M6 3月批次', NOW(), NOW()),
|
(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()),
|
(4, 2, 1, 4, 36, 420, 15120, 'BATCH-LZ-2024002', '泸州老窖特曲 春季批次', NOW(), NOW()),
|
||||||
(5, 2, 1, 5, 24, 390, 9360, 'BATCH-JNC-2024001', '剑南春水晶剑 首批', NOW(), NOW());
|
(5, 2, 1, 5, 24, 390, 9360, 'BATCH-JNC-2024001', '剑南春水晶剑 首批', NOW(), NOW());
|
||||||
|
|
||||||
-- #3 已审核: 拉菲+人头马,进口酒专库,郎酒供应商(代理进口)| 总额=108480
|
-- #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
|
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
|
||||||
(3, 1, 'RK20260404001', 'purchase', 2, 4, 2,
|
(3, 1, 'RK20260404001', 'purchase', 2, 4, 2,
|
||||||
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 108480,
|
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 108480,
|
||||||
DATE_SUB(NOW(), INTERVAL 2 DAY), '进口烈酒专库入库,含拉菲及人头马', NOW(), NOW());
|
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
|
batch_no, remark, created_at, updated_at) VALUES
|
||||||
(6, 3, 1, 7, 24, 3800, 91200, 'BATCH-LF-2018001', '拉菲古堡2018,原箱', NOW(), NOW()),
|
(6, 3, 1, 7, 24, 3800, 91200, 'BATCH-LF-2018001', '拉菲古堡2018,原箱', NOW(), NOW()),
|
||||||
(7, 3, 1, 8, 36, 480, 17280, 'BATCH-RTM-2024001', '人头马VSOP,2024年进口', NOW(), NOW());
|
(7, 3, 1, 8, 36, 480, 17280, 'BATCH-RTM-2024001', '人头马VSOP,2024年进口', NOW(), NOW());
|
||||||
|
|
||||||
-- #4 待审核: 郎酒补货,主仓库 | 总额=19200
|
-- #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
|
status, order_date, total_amount, remark, created_at, updated_at) VALUES
|
||||||
(4, 1, 'RK20260406001', 'purchase', 1, 4, 2,
|
(4, 1, 'RK20260406001', 'purchase', 1, 4, 2,
|
||||||
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 19200,
|
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 19200,
|
||||||
'郎酒红花郎补货,待仓库管理员审核', NOW(), NOW());
|
'郎酒红花郎补货,待仓库管理员审核', 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
|
batch_no, remark, created_at, updated_at) VALUES
|
||||||
(8, 4, 1, 6, 60, 320, 19200, 'BATCH-LJ-2024002', '郎酒红花郎10 第二批次', NOW(), NOW());
|
(8, 4, 1, 6, 60, 320, 19200, 'BATCH-LJ-2024002', '郎酒红花郎10 第二批次', NOW(), NOW());
|
||||||
|
|
||||||
-- #5 草稿: 追加茅台,主仓库 | 总额=142800
|
-- #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
|
status, order_date, total_amount, remark, created_at, updated_at) VALUES
|
||||||
(5, 1, 'RK20260407001', 'purchase', 1, 1, 2,
|
(5, 1, 'RK20260407001', 'purchase', 1, 1, 2,
|
||||||
'draft', CURDATE(), 142800,
|
'draft', CURDATE(), 142800,
|
||||||
'茅台追加订货,草稿中', NOW(), NOW());
|
'茅台追加订货,草稿中', 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
|
batch_no, remark, created_at, updated_at) VALUES
|
||||||
(9, 5, 1, 1, 60, 2380, 142800, 'BATCH-MT-2024002', '飞天茅台 第二批次', NOW(), NOW());
|
(9, 5, 1, 1, 60, 2380, 142800, 'BATCH-MT-2024002', '飞天茅台 第二批次', NOW(), NOW());
|
||||||
|
|
||||||
-- ── 出库单 ──────────────────────────────────────────────────
|
-- ── 出库单 ──────────────────────────────────────────────────
|
||||||
-- #1 已审核: 君悦大酒店,主仓库 | 总额=48960
|
-- #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
|
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
|
||||||
(1, 1, 'CK20260404001', 'sale', 1, 5, 2,
|
(1, 1, 'CK20260404001', 'sale', 1, 5, 2,
|
||||||
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 48960,
|
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 48960,
|
||||||
DATE_SUB(NOW(), INTERVAL 2 DAY), '北京君悦大酒店 4月份定期供货', NOW(), NOW());
|
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
|
remark, created_at, updated_at) VALUES
|
||||||
(1, 1, 1, 1, 12, 2800, 33600, '飞天茅台 12瓶,整箱出库', NOW(), NOW()),
|
(1, 1, 1, 1, 12, 2800, 33600, '飞天茅台 12瓶,整箱出库', NOW(), NOW()),
|
||||||
(2, 1, 1, 2, 6, 1200, 7200, '五粮液普五 6瓶', NOW(), NOW()),
|
(2, 1, 1, 2, 6, 1200, 7200, '五粮液普五 6瓶', NOW(), NOW()),
|
||||||
(3, 1, 1, 3, 12, 680, 8160, '洋河梦之蓝 M6 12瓶', NOW(), NOW());
|
(3, 1, 1, 3, 12, 680, 8160, '洋河梦之蓝 M6 12瓶', NOW(), NOW());
|
||||||
|
|
||||||
-- #2 已审核: 外滩华尔道夫,进口酒专库 | 总额=39360
|
-- #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
|
reviewer_id, status, order_date, total_amount, reviewed_at, remark, created_at, updated_at) VALUES
|
||||||
(2, 1, 'CK20260405001', 'sale', 2, 6, 2,
|
(2, 1, 'CK20260405001', 'sale', 2, 6, 2,
|
||||||
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 2 DAY), 39360,
|
1, 'approved', DATE_SUB(CURDATE(), INTERVAL 2 DAY), 39360,
|
||||||
DATE_SUB(NOW(), INTERVAL 1 DAY), '上海外滩华尔道夫 进口酒专属采购', NOW(), NOW());
|
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
|
remark, created_at, updated_at) VALUES
|
||||||
(4, 2, 1, 7, 6, 5200, 31200, '拉菲古堡2018 6瓶', NOW(), NOW()),
|
(4, 2, 1, 7, 6, 5200, 31200, '拉菲古堡2018 6瓶', NOW(), NOW()),
|
||||||
(5, 2, 1, 8, 12, 680, 8160, '人头马VSOP 12瓶', NOW(), NOW());
|
(5, 2, 1, 8, 12, 680, 8160, '人头马VSOP 12瓶', NOW(), NOW());
|
||||||
|
|
||||||
-- #3 待审核: 广州白天鹅宾馆,主仓库 | 总额=23280
|
-- #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
|
status, order_date, total_amount, remark, created_at, updated_at) VALUES
|
||||||
(3, 1, 'CK20260406001', 'sale', 1, 7, 2,
|
(3, 1, 'CK20260406001', 'sale', 1, 7, 2,
|
||||||
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 23280,
|
'pending', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 23280,
|
||||||
'广州白天鹅宾馆 月度供货申请', NOW(), NOW());
|
'广州白天鹅宾馆 月度供货申请', 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
|
remark, created_at, updated_at) VALUES
|
||||||
(6, 3, 1, 4, 24, 520, 12480, '泸州老窖特曲 24瓶', NOW(), NOW()),
|
(6, 3, 1, 4, 24, 520, 12480, '泸州老窖特曲 24瓶', NOW(), NOW()),
|
||||||
(7, 3, 1, 5, 12, 480, 5760, '剑南春水晶剑 12瓶', NOW(), NOW()),
|
(7, 3, 1, 5, 12, 480, 5760, '剑南春水晶剑 12瓶', NOW(), NOW()),
|
||||||
(8, 3, 1, 6, 12, 420, 5040, '郎酒红花郎10 12瓶', NOW(), NOW());
|
(8, 3, 1, 6, 12, 420, 5040, '郎酒红花郎10 12瓶', NOW(), NOW());
|
||||||
|
|
||||||
-- #4 草稿: 君悦追加,主仓库 | 总额=16800
|
-- #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
|
status, order_date, total_amount, remark, created_at, updated_at) VALUES
|
||||||
(4, 1, 'CK20260407001', 'sale', 1, 5, 2,
|
(4, 1, 'CK20260407001', 'sale', 1, 5, 2,
|
||||||
'draft', CURDATE(), 16800,
|
'draft', CURDATE(), 16800,
|
||||||
'北京君悦追加订单,草稿中', NOW(), NOW());
|
'北京君悦追加订单,草稿中', 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
|
remark, created_at, updated_at) VALUES
|
||||||
(9, 4, 1, 1, 6, 2800, 16800, '飞天茅台 追加 6瓶', NOW(), NOW());
|
(9, 4, 1, 1, 6, 2800, 16800, '飞天茅台 追加 6瓶', NOW(), NOW());
|
||||||
|
|
||||||
-- ── 库存(已审核单据计算后的实时库存)────────────────────
|
-- ── 库存(已审核单据计算后的实时库存)────────────────────
|
||||||
-- wh1: 飞天茅台 120-12=108, 五粮液 60-6=54, 洋河 48-12=36, 泸州 36, 剑南春 24
|
-- wh1: 飞天茅台 120-12=108, 五粮液 60-6=54, 洋河 48-12=36, 泸州 36, 剑南春 24
|
||||||
-- wh2: 拉菲 24-6=18, 人头马 36-12=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()),
|
(1, 1, 1, 1, 108, NOW()),
|
||||||
(2, 1, 1, 2, 54, NOW()),
|
(2, 1, 1, 2, 54, NOW()),
|
||||||
(3, 1, 1, 3, 36, 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
|
-- 入库单 #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
|
ref_type, ref_id, operator_id, created_at) VALUES
|
||||||
( 1, 1, 1, 1, 'in', 120, 0, 120, 'stock_in', 1, 1, NOW()),
|
( 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, 1, 1, 2, 'in', 60, 0, 60, 'stock_in', 1, 1, NOW());
|
||||||
|
|
||||||
-- 入库单 #2 (ref_id=2): 洋河+泸州+剑南春 → wh1
|
-- 入库单 #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
|
ref_type, ref_id, operator_id, created_at) VALUES
|
||||||
( 3, 1, 1, 3, 'in', 48, 0, 48, 'stock_in', 2, 1, NOW()),
|
( 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()),
|
( 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());
|
( 5, 1, 1, 5, 'in', 24, 0, 24, 'stock_in', 2, 1, NOW());
|
||||||
|
|
||||||
-- 入库单 #3 (ref_id=3): 拉菲+人头马 → wh2
|
-- 入库单 #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
|
ref_type, ref_id, operator_id, created_at) VALUES
|
||||||
( 6, 1, 2, 7, 'in', 24, 0, 24, 'stock_in', 3, 1, NOW()),
|
( 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());
|
( 7, 1, 2, 8, 'in', 36, 0, 36, 'stock_in', 3, 1, NOW());
|
||||||
|
|
||||||
-- 出库单 #1 (ref_id=1): 飞天茅台+五粮液+洋河 ← wh1
|
-- 出库单 #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
|
ref_type, ref_id, operator_id, created_at) VALUES
|
||||||
( 8, 1, 1, 1, 'out', 12, 120, 108, 'stock_out', 1, 1, NOW()),
|
( 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()),
|
( 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());
|
(10, 1, 1, 3, 'out', 12, 48, 36, 'stock_out', 1, 1, NOW());
|
||||||
|
|
||||||
-- 出库单 #2 (ref_id=2): 拉菲+人头马 ← wh2
|
-- 出库单 #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
|
ref_type, ref_id, operator_id, created_at) VALUES
|
||||||
(11, 1, 2, 7, 'out', 6, 24, 18, 'stock_out', 2, 1, NOW()),
|
(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());
|
(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 已回款
|
||||||
-- 应付账款:入库单 #1 (茅台 339000) + #2 (五粮液代理 51360) + #3 (郎酒 108480)
|
-- 应付账款:入库单 #1 (茅台 339000) + #2 (五粮液代理 51360) + #3 (郎酒 108480)
|
||||||
-- 付款:入库单 #1 已付款
|
-- 付款:入库单 #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
|
operator_id, record_date, remark, created_at, updated_at) VALUES
|
||||||
(1, 1, 5, 'receivable', 48960, 48960, 'stock_out', 1, 1,
|
(1, 1, 5, 'receivable', 48960, 48960, 'stock_out', 1, 1,
|
||||||
DATE_SUB(CURDATE(), INTERVAL 6 DAY), '君悦大酒店4月供货应收款', NOW(), NOW()),
|
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()),
|
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '进口烈酒专库入库应付款', NOW(), NOW()),
|
||||||
(7, 1, 1, 'payment', 100000, 0, 'stock_in', 1, 1,
|
(7, 1, 1, 'payment', 100000, 0, 'stock_in', 1, 1,
|
||||||
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '预付茅台货款10万,银行转账', NOW(), NOW());
|
DATE_SUB(CURDATE(), INTERVAL 3 DAY), '预付茅台货款10万,银行转账', NOW(), NOW());
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
+44
-56
@@ -1,83 +1,71 @@
|
|||||||
-- =============================================================
|
-- =============================================================
|
||||||
-- 测试门店 S002 种子数据(空库存版)
|
-- 测试门店 S002 种子数据(空库存版)
|
||||||
-- 用法: sh scripts/dev.sh seed S002
|
-- 用法: sh scripts/dev.sh seed S002
|
||||||
-- 说明: 基础数据与 S001 相同,入库/出库/库存数据为空,模拟新门店初始状态
|
-- 说明: 仅写入数据,不清空。加 --clear 参数先清空再写入
|
||||||
|
-- 模拟新门店初始状态,无历史入库/出库/库存数据
|
||||||
-- =============================================================
|
-- =============================================================
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
SET NAMES utf8mb4;
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
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)
|
INSERT IGNORE INTO shops (name, code, address, phone, manager_name, created_at, updated_at)
|
||||||
VALUES (1, '醇香汇酒业', 'S002', '上海市静安区南京西路1288号恒隆广场L1-06', '021-52088899', '李文博', NOW(), NOW());
|
VALUES ('醇香汇酒业', 'S002', '上海市静安区南京西路1288号恒隆广场L1-06', '021-52088899', '李文博', NOW(), NOW());
|
||||||
|
|
||||||
|
SET @shop_id = (SELECT id FROM shops WHERE code = 'S002' LIMIT 1);
|
||||||
|
|
||||||
-- ── 用户(密码均为 password123)────────────────────────────
|
-- ── 用户(密码均为 password123)────────────────────────────
|
||||||
SET @pwd = '$2a$10$BNHhJoKHryCCEyKqM.11TeLOnSCV8rNtOqvKHUqaczETXLtH/YE1m';
|
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
|
INSERT INTO users (shop_id, username, password_hash, real_name, phone, role, is_active, created_at, updated_at)
|
||||||
(1, 1, 'admin', @pwd, '张三(管理员)', '13800000001', 'admin', 1, NOW(), NOW()),
|
VALUES
|
||||||
(2, 1, 'operator', @pwd, '李四(操作员)', '13800000002', 'operator', 1, NOW(), NOW()),
|
(@shop_id, 'admin', @pwd, '李文博(管理员)', '13900000001', 'admin', 1, NOW(), NOW()),
|
||||||
(3, 1, 'test', @pwd, '王五(只读)', '13800000003', 'readonly', 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
|
INSERT IGNORE INTO warehouses (shop_id, name, location, is_default, created_at, updated_at) VALUES
|
||||||
(1, 1, '主仓库', 'A栋1层东侧', 1, NOW(), NOW()),
|
(@shop_id, '主仓库', 'A区1层', 1, NOW(), NOW()),
|
||||||
(2, 1, '进口酒专库', 'B栋2层恒温区', 0, NOW(), NOW());
|
(@shop_id, '进口酒专库', 'B区恒温展示区', 0, NOW(), NOW());
|
||||||
|
|
||||||
-- ── 商品分类 ────────────────────────────────────────────────
|
-- ── 商品分类 ────────────────────────────────────────────────
|
||||||
INSERT INTO product_categories (id, shop_id, name, sort_order, created_at, updated_at) VALUES
|
INSERT IGNORE INTO product_categories (shop_id, name, sort_order, created_at, updated_at) VALUES
|
||||||
(1, 1, '白酒', 1, NOW(), NOW()),
|
(@shop_id, '白酒', 1, NOW(), NOW()),
|
||||||
(2, 1, '进口烈酒', 2, 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
|
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()),
|
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000001', 'MT-001', '6901234567890', '飞天茅台 53度', '茅台', '500ml/瓶', '瓶', @cat_baijiu, '贵州茅台', 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()),
|
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000002', 'WLY-001', '6902345678901', '五粮液 52度', '五粮液', '500ml/瓶', '瓶', @cat_baijiu, '宜宾五粮液', 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()),
|
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000003', 'YH-001', '6903456789012', '洋河梦之蓝 M6', '洋河', '500ml/瓶', '瓶', @cat_baijiu, '江苏洋河', 560, 680, 6, '浓香型,绵柔苏酒代表', NOW(), NOW()),
|
||||||
(4, 1, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲 500ml', '泸州老窖', '500ml/瓶', '瓶', 1, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
|
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000004', 'LZ-001', '6904567890123', '泸州老窖 特曲', '泸州老窖', '500ml/瓶', '瓶', @cat_baijiu, '泸州老窖', 420, 520, 6, '浓香鼻祖,特曲系列', NOW(), NOW()),
|
||||||
(5, 1, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑 500ml', '剑南春', '500ml/瓶', '瓶', 1, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
|
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000005', 'JNC-001', '6905678901234', '剑南春 水晶剑', '剑南春', '500ml/瓶', '瓶', @cat_baijiu, '剑南春', 390, 480, 6, '浓香型,绵竹名酒', NOW(), NOW()),
|
||||||
(6, 1, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10 500ml', '郎酒', '500ml/瓶', '瓶', 1, '古蔺郎酒', 320, 420, 6, '酱香型,赤水河畔酿造', NOW(), NOW()),
|
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000006', 'LJ-001', '6906789012345', '郎酒 红花郎10', '郎酒', '500ml/瓶', '瓶', @cat_baijiu, '古蔺郎酒', 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()),
|
(@shop_id, 'b2c3d4e5-0002-0002-0002-000000000007', 'LF-001', '3760093550058', '拉菲古堡 2018', '波尔多', '750ml/瓶', '瓶', @cat_import, '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-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
|
credit_limit, balance, remark, created_at, updated_at) VALUES
|
||||||
(1, 1, 'SUP001', '贵州茅台酒股份有限公司', 'supplier', '张经理', '0851-22222001', '贵州省仁怀市茅台镇', '工商银行仁怀支行 6222 0000 0001 0001', 5000000, 0, '茅台系列直供,账期30天', NOW(), NOW()),
|
(@shop_id, '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()),
|
(@shop_id, '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()),
|
(@shop_id, '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()),
|
(@shop_id, '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()),
|
(@shop_id, 'CUS001', '上海外滩五号餐厅', 'customer', '林采购', '021-63869001', '上海市黄浦区外滩5号', '浦发银行上海黄浦支行 6210 0000 0003 0001', 300000, 0, '高端餐厅,月结', NOW(), NOW()),
|
||||||
(6, 1, 'CUS002', '上海外滩华尔道夫酒店', 'customer', '陈主任', '021-62308001', '上海市黄浦区中山东一路2号', '浦发银行上海黄浦支行 6210 0000 0002 0002', 800000, 0, '豪华酒店,按季结算', NOW(), NOW()),
|
(@shop_id, 'CUS002', '苏州虎丘山庄度假酒店', 'customer', '周总', '0512-66888001', '江苏省苏州市虎丘区虎丘路168号', '工商银行苏州支行 6222 0000 0003 0002', 200000, 0, '度假酒店,季结', NOW(), NOW());
|
||||||
(7, 1, 'CUS003', '广州白天鹅宾馆', 'customer', '吴采购', '020-81886001', '广州市荔湾区沙面南街1号', '招商银行广州荔湾支行 6225 0000 0002 0003', 300000, 0, '高端宾馆,月结30天', NOW(), NOW());
|
|
||||||
|
|
||||||
-- ── 编号规则(序号从 0 开始,无历史单据)──────────────────
|
-- ── 编号规则(序号从 0 开始,无历史单据)──────────────────
|
||||||
INSERT INTO number_rules (id, shop_id, type, prefix, current_no, date_format, updated_at) VALUES
|
INSERT IGNORE INTO number_rules (shop_id, type, prefix, current_no, date_format, updated_at) VALUES
|
||||||
(1, 1, 'stock_in', 'RK', 0, 'YYYYMMDD', NOW()),
|
(@shop_id, 'stock_in', 'RK', 0, 'YYYYMMDD', NOW()),
|
||||||
(2, 1, 'stock_out', 'CK', 0, 'YYYYMMDD', NOW()),
|
(@shop_id, 'stock_out', 'CK', 0, 'YYYYMMDD', NOW()),
|
||||||
(3, 1, 'inventory_check', 'PD', 0, 'YYYYMMDD', NOW());
|
(@shop_id, 'inventory_check', 'PD', 0, 'YYYYMMDD', NOW());
|
||||||
|
|
||||||
-- ── 入库单/出库单/库存/流水/财务:均为空 ──────────────────
|
-- ── 入库单/出库单/库存/流水/财务:均为空 ──────────────────
|
||||||
-- (模拟新门店初始状态,无历史数据)
|
-- 模拟新门店初始状态,无历史数据
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -56,6 +56,7 @@ func SetupTestDB() *gorm.DB {
|
|||||||
address TEXT,
|
address TEXT,
|
||||||
phone TEXT,
|
phone TEXT,
|
||||||
manager_name TEXT,
|
manager_name TEXT,
|
||||||
|
logo_url TEXT DEFAULT '',
|
||||||
business_license TEXT,
|
business_license TEXT,
|
||||||
business_hours TEXT,
|
business_hours TEXT,
|
||||||
shop_photos TEXT,
|
shop_photos TEXT,
|
||||||
@@ -349,10 +350,7 @@ func hashPassword(plain string) string {
|
|||||||
func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User {
|
func CreateTestUser(db *gorm.DB, shopID uint64, username, password, role string) *model.User {
|
||||||
hash := hashPassword(password)
|
hash := hashPassword(password)
|
||||||
user := &model.User{
|
user := &model.User{
|
||||||
TenantBase: model.TenantBase{
|
ShopID: shopID,
|
||||||
Base: model.Base{},
|
|
||||||
ShopID: shopID,
|
|
||||||
},
|
|
||||||
Username: username,
|
Username: username,
|
||||||
PasswordHash: hash,
|
PasswordHash: hash,
|
||||||
RealName: "Test User " + username,
|
RealName: "Test User " + username,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class ShopInfo {
|
|||||||
final String address;
|
final String address;
|
||||||
final String phone;
|
final String phone;
|
||||||
final String managerName;
|
final String managerName;
|
||||||
|
final String logoUrl;
|
||||||
|
|
||||||
const ShopInfo({
|
const ShopInfo({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -13,6 +14,7 @@ class ShopInfo {
|
|||||||
required this.address,
|
required this.address,
|
||||||
required this.phone,
|
required this.phone,
|
||||||
required this.managerName,
|
required this.managerName,
|
||||||
|
this.logoUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
|
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
|
||||||
@@ -22,5 +24,6 @@ class ShopInfo {
|
|||||||
address: json['address'] as String? ?? '',
|
address: json['address'] as String? ?? '',
|
||||||
phone: json['phone'] as String? ?? '',
|
phone: json['phone'] as String? ?? '',
|
||||||
managerName: json['manager_name'] as String? ?? '',
|
managerName: json['manager_name'] as String? ?? '',
|
||||||
|
logoUrl: json['logo_url'] as String? ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../core/api/api_client.dart';
|
import '../core/api/api_client.dart';
|
||||||
import '../core/exceptions.dart';
|
import '../core/exceptions.dart';
|
||||||
import '../models/shop.dart';
|
import '../models/shop.dart';
|
||||||
@@ -30,4 +31,19 @@ class ShopRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> uploadLogo(Uint8List bytes, String filename) async {
|
||||||
|
try {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': MultipartFile.fromBytes(bytes, filename: filename),
|
||||||
|
});
|
||||||
|
final resp = await _client.post('/shop/logo', data: formData);
|
||||||
|
return (resp.data as Map<String, dynamic>)['logo_url'] as String? ?? '';
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(
|
||||||
|
e.response?.data?['error'] as String? ?? 'Logo 上传失败',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,42 +99,97 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
final file = result.files.first;
|
final file = result.files.first;
|
||||||
final bytes = file.bytes;
|
final bytes = file.bytes;
|
||||||
if (bytes == null) return;
|
if (bytes == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
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';
|
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
if (!context.mounted) return;
|
final stateNotifier = ValueNotifier<_ImportState>(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0),
|
||||||
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': MultipartFile.fromBytes(bytes, filename: file.name),
|
'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;
|
final resp = await dio.post(
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
'/import/inventory',
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
data: formData,
|
||||||
content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? ',${errors.length} 条失败' : ''}'),
|
onSendProgress: (sent, total) {
|
||||||
backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent,
|
if (total <= 0) return;
|
||||||
duration: const Duration(seconds: 4),
|
if (sent >= total && !uploadDone) {
|
||||||
));
|
uploadDone = true;
|
||||||
ref.read(inventoryListProvider.notifier).reload();
|
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,
|
||||||
|
total: data['total'] ?? 0,
|
||||||
|
imported: data['imported'] ?? 0,
|
||||||
|
updated: data['updated'] ?? 0,
|
||||||
|
errors: (data['errors'] as List?)?.cast<String>() ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasErrors = (data['errors'] as List?)?.isNotEmpty ?? false;
|
||||||
|
if (!hasErrors) {
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
if (context.mounted) ref.read(inventoryListProvider.notifier).reload();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误';
|
processingTimer?.cancel();
|
||||||
if (!context.mounted) return;
|
final msg = (e.response?.data is Map ? e.response!.data['error'] : null)
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
?? e.message ?? '未知错误';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
stateNotifier.value = _ImportState(
|
||||||
content: Text('导入失败:$msg'),
|
stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg,
|
||||||
backgroundColor: AppTheme.danger,
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,3 +920,163 @@ class _DirectionBadge extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 导入进度对话框 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
enum _ImportStage { uploading, processing, done, error }
|
||||||
|
|
||||||
|
class _ImportState {
|
||||||
|
final _ImportStage stage;
|
||||||
|
final int uploadPercent;
|
||||||
|
final int processingSeconds;
|
||||||
|
final int total;
|
||||||
|
final int imported;
|
||||||
|
final int updated;
|
||||||
|
final List<String> errors;
|
||||||
|
final String? errorMsg;
|
||||||
|
|
||||||
|
const _ImportState({
|
||||||
|
required this.stage,
|
||||||
|
required this.uploadPercent,
|
||||||
|
this.processingSeconds = 0,
|
||||||
|
this.total = 0,
|
||||||
|
this.imported = 0,
|
||||||
|
this.updated = 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:
|
||||||
|
final allFailed = state.total == 0 && state.errors.isNotEmpty;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(
|
||||||
|
allFailed ? Icons.warning_amber_rounded : Icons.check_circle,
|
||||||
|
color: allFailed ? AppTheme.danger : AppTheme.success,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
allFailed ? '导入失败' : '导入完成',
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text('共 ${state.total} 行', style: const TextStyle(fontSize: 13)),
|
||||||
|
Text('成功插入:${state.imported} 行', style: const TextStyle(fontSize: 13)),
|
||||||
|
Text('重复更新:${state.updated} 行',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||||
|
if (state.errors.isNotEmpty)
|
||||||
|
Text('失败:${state.errors.length} 行',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||||
|
if (state.errors.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
...state.errors.map((e) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(e,
|
||||||
|
style: TextStyle(fontSize: 12, 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 '../../core/utils/dialog_util.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -126,14 +127,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_ShopInfoRow(
|
// Logo 预览行
|
||||||
label: '门店编号',
|
Row(
|
||||||
value: shop.code.isNotEmpty ? shop.code : '—'),
|
children: [
|
||||||
const Divider(height: 16),
|
_ShopLogoPreview(logoUrl: shop.logoUrl, shopName: shop.name, size: 64),
|
||||||
_ShopInfoRow(
|
const SizedBox(width: 16),
|
||||||
label: '门店名称',
|
Column(
|
||||||
value: shop.name.isNotEmpty ? shop.name : '—'),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const Divider(height: 16),
|
children: [
|
||||||
|
Text(shop.name.isNotEmpty ? shop.name : '—',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(shop.code.isNotEmpty ? shop.code : '—',
|
||||||
|
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
_ShopInfoRow(
|
_ShopInfoRow(
|
||||||
label: '门店地址',
|
label: '门店地址',
|
||||||
value: shop.address.isNotEmpty ? shop.address : '—'),
|
value: shop.address.isNotEmpty ? shop.address : '—'),
|
||||||
@@ -1524,10 +1535,23 @@ class _ImportSlot {
|
|||||||
int total = 0;
|
int total = 0;
|
||||||
int imported = 0;
|
int imported = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
|
int updated = 0;
|
||||||
|
// 进度
|
||||||
|
int uploadPercent = 0;
|
||||||
|
bool isProcessing = false;
|
||||||
|
int processingSeconds = 0;
|
||||||
|
|
||||||
_ImportSlot(this.title, this.endpoint, this.hint);
|
_ImportSlot(this.title, this.endpoint, this.hint);
|
||||||
|
|
||||||
bool get hasResult => success != null;
|
bool get hasResult => success != null;
|
||||||
|
|
||||||
|
void resetProgress() {
|
||||||
|
uploadPercent = 0;
|
||||||
|
isProcessing = false;
|
||||||
|
processingSeconds = 0;
|
||||||
|
success = null;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BatchImportWidget extends ConsumerStatefulWidget {
|
class _BatchImportWidget extends ConsumerStatefulWidget {
|
||||||
@@ -1657,14 +1681,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
for (final s in _slots) {
|
for (final s in _slots) {
|
||||||
if (s.file != null) {
|
if (s.file != null) s.resetProgress();
|
||||||
s.success = null;
|
|
||||||
s.error = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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';
|
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
for (final slot in _slots) {
|
for (final slot in _slots) {
|
||||||
@@ -1674,29 +1699,57 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer? processingTimer;
|
||||||
|
bool uploadDone = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
|
'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>{};
|
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = true;
|
slot.success = true;
|
||||||
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
|
|
||||||
slot.imported = (data['imported'] ?? 0) as int;
|
slot.imported = (data['imported'] ?? 0) as int;
|
||||||
slot.skipped = (data['skipped'] ?? 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) {
|
} on DioException catch (e) {
|
||||||
|
processingTimer?.cancel();
|
||||||
final raw = e.response?.data;
|
final raw = e.response?.data;
|
||||||
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = false;
|
slot.success = false;
|
||||||
slot.error = msg.toString();
|
slot.error = msg.toString();
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
processingTimer?.cancel();
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = false;
|
slot.success = false;
|
||||||
slot.error = e.toString();
|
slot.error = e.toString();
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2017,10 +2070,11 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
Widget? statusWidget;
|
Widget? statusWidget;
|
||||||
if (slot.hasResult) {
|
if (slot.hasResult) {
|
||||||
if (slot.success == true) {
|
if (slot.success == true) {
|
||||||
|
final duplicate = slot.skipped + slot.updated;
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
'共 ${slot.total} 条',
|
'共 ${slot.total} 条',
|
||||||
'新增 ${slot.imported} 条',
|
'新增 ${slot.imported} 条',
|
||||||
if (slot.skipped > 0) '重复跳过 ${slot.skipped} 条',
|
if (duplicate > 0) '重复 $duplicate 条',
|
||||||
];
|
];
|
||||||
statusWidget = Row(
|
statusWidget = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -2047,10 +2101,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (_loading && slot.file != null) {
|
} else if (_loading && slot.file != null) {
|
||||||
statusWidget = const SizedBox(
|
if (slot.isProcessing) {
|
||||||
width: 14, height: 14,
|
statusWidget = Row(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
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(
|
return Padding(
|
||||||
@@ -2104,6 +2190,54 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 门店 Logo 预览 ────────────────────────────────────────
|
||||||
|
class _ShopLogoPreview extends StatelessWidget {
|
||||||
|
final String logoUrl;
|
||||||
|
final String shopName;
|
||||||
|
final double size;
|
||||||
|
const _ShopLogoPreview({required this.logoUrl, required this.shopName, this.size = 64});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final radius = BorderRadius.circular(size * 0.19);
|
||||||
|
if (logoUrl.isNotEmpty) {
|
||||||
|
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Image.network(
|
||||||
|
fullUrl,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _initial(radius),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _initial(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _initial(BorderRadius radius) {
|
||||||
|
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF0F3057),
|
||||||
|
borderRadius: radius,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: size * 0.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 酒行信息行 ────────────────────────────────────────────
|
// ── 酒行信息行 ────────────────────────────────────────────
|
||||||
class _ShopInfoRow extends StatelessWidget {
|
class _ShopInfoRow extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
@@ -2149,6 +2283,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
|||||||
late final TextEditingController _phoneCtrl;
|
late final TextEditingController _phoneCtrl;
|
||||||
late final TextEditingController _managerCtrl;
|
late final TextEditingController _managerCtrl;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
bool _uploadingLogo = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -2168,6 +2303,37 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadLogo() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['jpg', 'jpeg', 'png'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.bytes == null) return;
|
||||||
|
setState(() => _uploadingLogo = true);
|
||||||
|
try {
|
||||||
|
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
|
||||||
|
ref.invalidate(shopInfoProvider);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('Logo 已更新'),
|
||||||
|
backgroundColor: AppTheme.success,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('上传失败:$e'),
|
||||||
|
backgroundColor: AppTheme.danger,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _uploadingLogo = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
@@ -2206,6 +2372,25 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// Logo 上传行
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ShopLogoPreview(
|
||||||
|
logoUrl: ref.watch(shopInfoProvider).valueOrNull?.logoUrl ?? widget.shop.logoUrl,
|
||||||
|
shopName: _nameCtrl.text,
|
||||||
|
size: 56,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _uploadingLogo ? null : _uploadLogo,
|
||||||
|
icon: _uploadingLogo
|
||||||
|
? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.upload_outlined, size: 16),
|
||||||
|
label: const Text('更换 Logo'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nameCtrl,
|
controller: _nameCtrl,
|
||||||
decoration: const InputDecoration(labelText: '门店名称'),
|
decoration: const InputDecoration(labelText: '门店名称'),
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../../core/auth/auth_state.dart';
|
import '../../core/auth/auth_state.dart';
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../providers/connectivity_provider.dart';
|
import '../../providers/connectivity_provider.dart';
|
||||||
|
import '../../providers/shop_provider.dart';
|
||||||
import '../../providers/update_provider.dart';
|
import '../../providers/update_provider.dart';
|
||||||
|
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
@@ -575,27 +577,31 @@ void _showShopPanel(BuildContext context, AuthUser u, {String version = 'v1.0.0'
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ShopButton extends StatelessWidget {
|
class _ShopButton extends ConsumerWidget {
|
||||||
final AuthUser? user;
|
final AuthUser? user;
|
||||||
final String version;
|
final String version;
|
||||||
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
const _ShopButton({this.user, this.version = 'v1.0.0'});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final shopAsync = ref.watch(shopInfoProvider);
|
||||||
|
final shopName = shopAsync.valueOrNull?.name ?? user?.shopNo ?? '';
|
||||||
|
final logoUrl = shopAsync.valueOrNull?.logoUrl ?? '';
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
cursor: SystemMouseCursors.click,
|
cursor: SystemMouseCursors.click,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (user != null) _showShopPanel(context, user!, version: version);
|
if (user != null) _showShopPanel(context, user!, version: version);
|
||||||
},
|
},
|
||||||
child: const Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_YanmeiMark(size: 28),
|
_ShopLogo(logoUrl: logoUrl, shopName: shopName, size: 28),
|
||||||
SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
'岩美',
|
shopName.isEmpty ? '—' : shopName,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -608,70 +614,54 @@ class _ShopButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Brand mark widget — approximates the 岩美 logo SVG without flutter_svg.
|
/// 门店 Logo:有图片显示网络图片,无图片显示店名首字文字头像。
|
||||||
/// Dark blue rounded rect, white mountain/wave strokes, bordeaux dot.
|
class _ShopLogo extends StatelessWidget {
|
||||||
class _YanmeiMark extends StatelessWidget {
|
final String logoUrl;
|
||||||
|
final String shopName;
|
||||||
final double size;
|
final double size;
|
||||||
const _YanmeiMark({this.size = 32});
|
const _ShopLogo({required this.logoUrl, required this.shopName, this.size = 32});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final radius = BorderRadius.circular(size * 0.19);
|
||||||
|
if (logoUrl.isNotEmpty) {
|
||||||
|
final fullUrl = logoUrl.startsWith('http') ? logoUrl : '${AppConfig.apiBaseUrl.replaceAll('/api/v1', '')}$logoUrl';
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Image.network(
|
||||||
|
fullUrl,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => _initial(radius),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _initial(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _initial(BorderRadius radius) {
|
||||||
|
final initial = shopName.isNotEmpty ? shopName.characters.first : '店';
|
||||||
return Container(
|
return Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF0F3057),
|
color: const Color(0xFF0F3057),
|
||||||
borderRadius: BorderRadius.circular(size * 0.19),
|
borderRadius: radius,
|
||||||
),
|
),
|
||||||
child: CustomPaint(
|
alignment: Alignment.center,
|
||||||
painter: _YanmeiMarkPainter(),
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: size * 0.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _YanmeiMarkPainter extends CustomPainter {
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
final w = size.width;
|
|
||||||
final h = size.height;
|
|
||||||
final paint = Paint()
|
|
||||||
..color = Colors.white
|
|
||||||
..strokeWidth = w * 0.055
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeCap = StrokeCap.round
|
|
||||||
..strokeJoin = StrokeJoin.round;
|
|
||||||
|
|
||||||
// Mountain/wave path: M14 38 L22 22 L32 32 L42 22 L50 38 (on 64px grid)
|
|
||||||
final path = Path();
|
|
||||||
path.moveTo(w * 0.219, h * 0.594);
|
|
||||||
path.lineTo(w * 0.344, h * 0.344);
|
|
||||||
path.lineTo(w * 0.500, h * 0.500);
|
|
||||||
path.lineTo(w * 0.656, h * 0.344);
|
|
||||||
path.lineTo(w * 0.781, h * 0.594);
|
|
||||||
canvas.drawPath(path, paint);
|
|
||||||
|
|
||||||
// Horizontal baseline: M12 46 L52 46 (on 64px grid)
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(w * 0.1875, h * 0.719),
|
|
||||||
Offset(w * 0.8125, h * 0.719),
|
|
||||||
paint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bordeaux dot: circle cx=32 cy=52 r=2.4 (on 64px grid)
|
|
||||||
canvas.drawCircle(
|
|
||||||
Offset(w * 0.500, h * 0.859),
|
|
||||||
w * 0.042,
|
|
||||||
Paint()
|
|
||||||
..color = const Color(0xFFC97B86)
|
|
||||||
..style = PaintingStyle.fill,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
class _InfoRow extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ server {
|
|||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
# 商品图片静态文件
|
# 商品图片静态文件
|
||||||
location ^~ /images/ {
|
location ^~ /images/ {
|
||||||
alias /opt/jiu/images/;
|
alias /opt/jiu/images/;
|
||||||
@@ -14,6 +16,14 @@ server {
|
|||||||
add_header Cache-Control "public, immutable";
|
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 反向代理
|
# API 反向代理
|
||||||
location ~ ^/(api|health|version) {
|
location ~ ^/(api|health|version) {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|||||||
+28
-6
@@ -36,12 +36,22 @@ WEB_PID_FILE="$LOG_DIR/web.pid"
|
|||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
# ── seed 命令 ────────────────────────────────────────────────
|
# ── seed 命令 ────────────────────────────────────────────────
|
||||||
# 用法: cmd_seed <shop_code>
|
# 用法: cmd_seed [--clear] <shop_code>
|
||||||
# 从 backend/config/config.yaml 读取 DSN,执行 backend/seeds/<shop>.sql
|
# 从 docker-compose.yml 读取容器信息,执行 backend/seeds/<shop>.sql
|
||||||
|
# --clear: 写入前先清空该门店所有数据
|
||||||
cmd_seed() {
|
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
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -64,6 +74,17 @@ cmd_seed() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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}"
|
info "写入 ${shop} 种子数据 → 容器 ${container} / ${db_name}"
|
||||||
docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name" < "$sql_file"
|
docker exec -i "$container" mysql --default-character-set=utf8mb4 -uroot -p"$db_pass" "$db_name" < "$sql_file"
|
||||||
success "✅ ${shop} 种子数据写入完成"
|
success "✅ ${shop} 种子数据写入完成"
|
||||||
@@ -107,7 +128,8 @@ show_help() {
|
|||||||
echo " --web-only 仅启动前端(Web)"
|
echo " --web-only 仅启动前端(Web)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "数据库命令:"
|
echo "数据库命令:"
|
||||||
echo " seed <shop_code> 清空并写入指定门店测试数据(如 H001)"
|
echo " seed <shop_code> 写入指定门店测试数据(如 S001)"
|
||||||
|
echo " seed --clear <shop> 先清空再写入(单店)"
|
||||||
echo " reset 删表重建(AutoMigrate)"
|
echo " reset 删表重建(AutoMigrate)"
|
||||||
echo " clear 清空所有业务数据"
|
echo " clear 清空所有业务数据"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -135,7 +157,7 @@ case "$COMMAND" in
|
|||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
seed)
|
seed)
|
||||||
cmd_seed "${2:-}"
|
cmd_seed "${2:-}" "${3:-}"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
reset)
|
reset)
|
||||||
|
|||||||
Reference in New Issue
Block a user