Compare commits
12 Commits
server-v1.1.11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 658df690f6 | |||
| e19c498dba | |||
| 39a2f14e0b | |||
| 2c25bbfd85 | |||
| b20be72155 | |||
| fb8337b156 | |||
| 05203a9b5b | |||
| d1b7e8e2e9 | |||
| 21a4a8cab7 | |||
| 8912b8ef7f | |||
| 33987faa9b | |||
| 7328d85912 |
@@ -40,7 +40,7 @@ export PATH="/opt/homebrew/bin:$PATH"
|
||||
```
|
||||
- **client**:
|
||||
```bash
|
||||
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
|
||||
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings && flutter test
|
||||
```
|
||||
- **site**:
|
||||
```bash
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Design Source Checks
|
||||
|
||||
# 设计真相源检查(治理规则见 CLAUDE.md「设计真相源分层」):
|
||||
# PR 与合并 main 时自动跑四道闸,全部纯静态检查(node + dart format),
|
||||
# 不跑 golden/fidelity(重型像素闸维持「定期体检」定位,本地手动跑)。
|
||||
# PR 与合并 main 时自动跑:四道静态闸(node + dart format)+ flutter test
|
||||
# (单测 + golden ×3 主题回归自比,2026-07-07 纳入——此前 DoD 写了但无闸)。
|
||||
# fidelity 重型像素闸维持「定期体检」定位,本地手动跑。
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
@@ -23,7 +24,7 @@ jobs:
|
||||
run: sh scripts/ci/provision-mac.sh
|
||||
|
||||
- name: 原型设计系统闸(check-ds 12 道)
|
||||
run: node .superpowers/prototype/tools/check-ds.mjs
|
||||
run: node design/prototype/tools/check-ds.mjs
|
||||
|
||||
- name: L1 真相源同源闸(tokens 快照 / icons / 官网 token 值 / web hex)
|
||||
run: node tools/check-l1-sync.mjs
|
||||
@@ -37,3 +38,8 @@ jobs:
|
||||
node tool/gen_tokens.mjs
|
||||
dart format lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
|
||||
git diff --exit-code -- lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
|
||||
|
||||
- name: Flutter 测试(单测 + golden ×3 主题回归自比)
|
||||
run: |
|
||||
cd client
|
||||
flutter test
|
||||
|
||||
@@ -67,10 +67,8 @@ backend/gencode
|
||||
# iOS 证书私钥(绝不入库)
|
||||
.ios-certs/
|
||||
|
||||
# superpowers 工作目录:brainstorm/sdd 是草稿不入库;
|
||||
# prototype 是设计像素真源 + 设计系统守门闸(check-ds.mjs),入库供 CI 跑。
|
||||
.superpowers/*
|
||||
!.superpowers/prototype/
|
||||
# superpowers 工作目录(草稿不入库)。原型已迁 design/prototype/(2026-07-07)。
|
||||
.superpowers/
|
||||
|
||||
# design-distill 保真目检对比图(可重生,见 tools/ds-compare.mjs)
|
||||
design/_compare/
|
||||
|
||||
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.8] - 2026-07-10
|
||||
|
||||
### 新功能
|
||||
- 库存列表(手机端)商品名称后新增生产日期小字,一眼区分同名不同批次的商品。
|
||||
- 商品详情抽屉新增「生产日期」信息行。
|
||||
|
||||
## [1.1.7] - 2026-07-07
|
||||
|
||||
### 新功能
|
||||
- 库存列表列头排序:点击 库存 / 成本价 / 总价 / 生产日期 / 入库时间 列头
|
||||
即可全局排序(升序 ↑ / 降序 ↓ / 再点恢复默认)。
|
||||
|
||||
### 改进
|
||||
- 库存筛选全部移到工具栏(与出库管理同一形式):规格 / 系列 / 仓库 / 状态
|
||||
改为工具栏下拉标签,可单独清除;列头不再放筛选漏斗。
|
||||
|
||||
## [1.1.6] - 2026-07-07
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -257,7 +257,7 @@ cd client && flutter test
|
||||
|
||||
### 设计真相源分层(原型漂移治理,2026-07-04 拍板)
|
||||
|
||||
原型(`.superpowers/prototype/`)与真实页面**不追求全量永久同步**,按层分治:
|
||||
原型(`design/prototype/`)与真实页面**不追求全量永久同步**,按层分治:
|
||||
|
||||
- **L1 设计系统层**(`tokens.css` / `atoms.css` / `mobile-atoms.css` / `icons.js` + `index.html` 登记):原型是**单一真源**。新增颜色/组件/图标必须**先登记原型再同步**代码与官网(如 `web/_includes/icons.njk`)。闸:pre-commit `check-ds.mjs` 12 道(本地钩子,新机器先跑 `sh scripts/hooks/install.sh`)+ 代码侧 `client/tool/check_ds_code.mjs`。
|
||||
- **L2 屏级三态**(台账 = `design/CONTRACT.md` 块 4「真相源」列):
|
||||
@@ -268,16 +268,16 @@ cd client && flutter test
|
||||
- **漂移体检**:不逐提交强制同步;大版本/整屏改版前跑 `node tools/fidelity.mjs`,按报告逐屏决定「重新对齐原型」或「降级快照」,结果回填 CONTRACT。
|
||||
- **CI 闸**(`.gitea/workflows/checks.yml`,PR + main push 自动跑):① `check-ds.mjs` 原型 12 道;② `tools/check-l1-sync.mjs` L1 同源四道(tokens 快照逐字节 / icons 两端同集同内容 / 官网 token 值对齐原型主题 A / web 硬编码 hex,白名单 #fff、#1677ff,`ds-allow` 可豁免);③ `client/tool/check_ds_code.mjs` Flutter 颜色单源;④ codegen 新鲜度(regen→`dart format`→与入库产物零 diff)。本地可直接跑同名命令。
|
||||
|
||||
### 表格与列头筛选(ds 真相源)
|
||||
- 列表屏统一 `DsTable`(`widgets/ds/ds_table.dart`:toolbar+表格+pager 连成一卡),列定义 `DsColumn`,可筛选列走其列头漏斗(`filtered` 态高亮),可隐藏列走列设置菜单
|
||||
- 不要在 toolbar 放独立筛选按钮,筛选入口内嵌列头或工具栏 `DsChip`
|
||||
### 表格与筛选/排序(ds 真相源)
|
||||
- 列表屏统一 `DsTable`(`widgets/ds/ds_table.dart`:toolbar+表格+pager 连成一卡),列定义 `DsColumn`,可隐藏列走列设置菜单
|
||||
- **筛选统一放工具栏 `DsChip`**(2026-07-07 用户口径,形式参照出库管理:单选/多选 chip + 选中值/N 项摘要 + × 单清);列头**只放排序** `DsSortHeader`(点击循环 无→升→降→无,服务端全局排序)。列头漏斗 `DsFilterHeader` 已退役删除
|
||||
- 旧 `FilterableColumnHeader`/`multi_select_dropdown.dart` 仅存量引用,勿在新屏使用
|
||||
|
||||
### 响应式 / 移动端适配
|
||||
- 断点判定统一用 `context.isMobile`(`client/lib/core/responsive/responsive.dart`,宽度 < 600 为窄屏/手机),**禁止**在各处散落 `MediaQuery.width < 600` 这类魔法数
|
||||
- **列表屏**:统一用 `DsTable`(`widgets/ds/ds_table.dart`,真相源表格:toolbar+列+pager);窄屏卡片流复用 `MobileListCard` / `MobileCardField`(`widgets/mobile_list_card.dart`)。旧 `DataTableCard` 已删除(2026-07-03),勿再引用
|
||||
- **列表屏**:统一用 `DsTable`(`widgets/ds/ds_table.dart`,真相源表格:toolbar+列+pager);窄屏卡片流用 `MCard`(`widgets/ds/m_card.dart`,镜像原型 mobile-atoms)。旧 `MobileListCard` 为 legacy 仅存量引用(迁移在 todo),旧 `DataTableCard` 已删除(2026-07-03),勿再引用
|
||||
- **弹窗固定宽度**:一律用 `context.dialogWidth(X)`(≤ 屏宽 92%),**禁止**裸写 `width: <固定值>` 导致窄屏溢出
|
||||
- **导航**:窄屏(手机)用 Drawer 抽屉(`app_shell.dart`),宽屏保持侧边栏;新增页面无需单独处理,挂在 shell 下即可
|
||||
- **导航**:窄屏(手机)= 底部 5 tab(库存/入库/出库/财务/我的)+「我的」hub 聚合二级屏(`app_shell.dart` + `m_tab_bar.dart`,2026-07-04 起,Drawer 已退役);宽屏保持侧边栏;一切弹层窄屏统一走 `showMSheet` 底部 sheet
|
||||
- 平台判断仍遵守上面「前端平台判断」规则(`kIsWeb` 先于 `dart:io`)
|
||||
|
||||
### 权限中间件(已全局挂载)
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// fix-history-prices —— 一次性修复 import-history 出库明细的价格错列。
|
||||
//
|
||||
// 根因:旧系统出库单明细的「单价/金额」是售价口径(卖给客户的价,应收侧),
|
||||
// import-history 却把它落到了 cost_price/cost_amount(成本列),sale_price/
|
||||
// sale_amount 留 0(整单显示「待定价」);而单头合计已按售价口径落 sale_total,
|
||||
// 明细与单头自相矛盾。实证:ZXZ027628 出库明细"成本"14300 = 售价,
|
||||
// 真实进价 13600 在库存/入库侧。
|
||||
//
|
||||
// 修复(仅动历史导入明细,即 product_id 指向 HIST-PLACEHOLDER 占位商品的行):
|
||||
// - sale_price ← 原 cost_price、sale_amount ← 原 cost_amount(源售价归位)
|
||||
// - cost_price/cost_amount ← 按商品编号(序列号,一物一码)回查真实进价:
|
||||
// ① stock_in_items 同店同编号入库明细价 ② inventories.unit_price 库存快照价
|
||||
// ③ products.purchase_price 商品主数据进价;均查不到 → 0(成本待定)
|
||||
// - 涉及单据 profit_total 按 service.recalcStockOutProfit 同口径重算
|
||||
// - sale_total 不动(= 源单头合计,应收不变,财务无需冲账)
|
||||
//
|
||||
// 幂等:只处理 sale_price=0 且 cost_price>0 的占位明细,重跑自动跳过已修行。
|
||||
// 默认 dry-run(事务内试算后回滚),--apply 才真正落库。
|
||||
//
|
||||
// 用法(在 backend/ 目录下执行):
|
||||
//
|
||||
// go run ./cmd/fix-history-prices --shop-code S001 # dry-run
|
||||
// go run ./cmd/fix-history-prices --shop-code S001 --apply # 落库
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
const placeholderProductCode = "HIST-PLACEHOLDER"
|
||||
|
||||
// errDryRun 哨兵错误:dry-run 模式下用它触发事务回滚(试算走完真实 SQL 但不落库)。
|
||||
var errDryRun = errors.New("dry-run rollback")
|
||||
|
||||
type fixStats struct {
|
||||
Items int // 修复的出库明细行数
|
||||
CostFromStockIn int // 成本回填来源:入库明细
|
||||
CostFromInventory int // 成本回填来源:库存快照
|
||||
CostFromProduct int // 成本回填来源:商品主数据进价
|
||||
CostUnmatched int // 查无进价(cost 置 0,成本待定)
|
||||
Orders int // 涉及并重算利润的出库单数
|
||||
TotalMismatches []string // Σ明细售价小计 ≠ 单头 sale_total 的单号(只报不改)
|
||||
}
|
||||
|
||||
type outItemRow struct {
|
||||
ID uint64
|
||||
OrderID uint64
|
||||
ProductCode string
|
||||
Quantity float64
|
||||
CostPrice float64
|
||||
CostAmount float64
|
||||
}
|
||||
|
||||
type costHit struct {
|
||||
price float64
|
||||
src string // stock_in / inventory / product / ""(unmatched)
|
||||
}
|
||||
|
||||
func round2(x float64) float64 { return math.Round(x*100) / 100 }
|
||||
|
||||
// lookupCost 按商品编号回查真实进价(商品编号=序列号,一物一码,匹配精确)。
|
||||
func lookupCost(tx *gorm.DB, shopID uint64, code string) costHit {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return costHit{}
|
||||
}
|
||||
var p float64
|
||||
// ① 同店同编号的入库明细成本(历史导入入库价与正常入库价同源)
|
||||
row := tx.Raw(`SELECT cost_price FROM stock_in_items
|
||||
WHERE shop_id = ? AND product_code = ? AND cost_price > 0
|
||||
ORDER BY id DESC LIMIT 1`, shopID, code).Row()
|
||||
if row.Scan(&p) == nil && p > 0 {
|
||||
return costHit{p, "stock_in"}
|
||||
}
|
||||
// ② 库存快照价(快照列或经 product 关联均可命中)
|
||||
row = tx.Raw(`SELECT inv.unit_price FROM inventories inv
|
||||
LEFT JOIN products pr ON pr.id = inv.product_id AND pr.deleted_at IS NULL
|
||||
WHERE inv.shop_id = ? AND inv.deleted_at IS NULL AND inv.unit_price > 0
|
||||
AND (inv.product_code = ? OR pr.code = ?)
|
||||
ORDER BY inv.id DESC LIMIT 1`, shopID, code, code).Row()
|
||||
if row.Scan(&p) == nil && p > 0 {
|
||||
return costHit{p, "inventory"}
|
||||
}
|
||||
// ③ 商品主数据进价
|
||||
row = tx.Raw(`SELECT purchase_price FROM products
|
||||
WHERE shop_id = ? AND code = ? AND deleted_at IS NULL AND purchase_price > 0
|
||||
ORDER BY id DESC LIMIT 1`, shopID, code).Row()
|
||||
if row.Scan(&p) == nil && p > 0 {
|
||||
return costHit{p, "product"}
|
||||
}
|
||||
return costHit{}
|
||||
}
|
||||
|
||||
// fixShop 修复一家店的历史导入出库明细价格。apply=false 时事务回滚(dry-run)。
|
||||
func fixShop(db *gorm.DB, shopID uint64, apply bool) (*fixStats, error) {
|
||||
// 占位商品可能已被用户在界面软删(线上即如此)——历史明细仍引用其 id,
|
||||
// 展示走快照列不受影响,这里按 id 定位范围即可,故不过滤 deleted_at。
|
||||
var ph model.Product
|
||||
if err := db.Where("shop_id = ? AND code = ?",
|
||||
shopID, placeholderProductCode).First(&ph).Error; err != nil {
|
||||
return nil, fmt.Errorf("未找到占位商品 %s(该店没有历史导入数据?): %w", placeholderProductCode, err)
|
||||
}
|
||||
|
||||
st := &fixStats{}
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var items []outItemRow
|
||||
if err := tx.Raw(`SELECT id, order_id, product_code, quantity, cost_price, cost_amount
|
||||
FROM stock_out_items
|
||||
WHERE shop_id = ? AND product_id = ? AND sale_price = 0 AND cost_price > 0
|
||||
ORDER BY id`, shopID, ph.ID).Scan(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
st.Items = len(items)
|
||||
|
||||
cache := map[string]costHit{}
|
||||
orderIDs := map[uint64]bool{}
|
||||
for _, it := range items {
|
||||
hit, ok := cache[it.ProductCode]
|
||||
if !ok {
|
||||
hit = lookupCost(tx, shopID, it.ProductCode)
|
||||
cache[it.ProductCode] = hit
|
||||
}
|
||||
switch hit.src {
|
||||
case "stock_in":
|
||||
st.CostFromStockIn++
|
||||
case "inventory":
|
||||
st.CostFromInventory++
|
||||
case "product":
|
||||
st.CostFromProduct++
|
||||
default:
|
||||
st.CostUnmatched++
|
||||
}
|
||||
if err := tx.Exec(`UPDATE stock_out_items
|
||||
SET sale_price = ?, sale_amount = ?, cost_price = ?, cost_amount = ?
|
||||
WHERE id = ?`,
|
||||
it.CostPrice, it.CostAmount, hit.price, round2(it.Quantity*hit.price), it.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
orderIDs[it.OrderID] = true
|
||||
}
|
||||
st.Orders = len(orderIDs)
|
||||
|
||||
if len(orderIDs) > 0 {
|
||||
ids := make([]uint64, 0, len(orderIDs))
|
||||
for id := range orderIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// 与 service.recalcStockOutProfit 同口径重算总利润
|
||||
if err := tx.Exec(`UPDATE stock_out_orders SET profit_total = (
|
||||
SELECT COALESCE(SUM(CASE WHEN i.sale_price > 0
|
||||
THEN (i.sale_price - i.cost_price) * i.quantity ELSE 0 END), 0)
|
||||
FROM stock_out_items i WHERE i.order_id = stock_out_orders.id
|
||||
) WHERE shop_id = ? AND id IN ?`, shopID, ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 校验:修复后 Σ明细售价小计 应与源单头合计 sale_total 一致(只报不改)
|
||||
if err := tx.Raw(`SELECT o.order_no FROM stock_out_orders o
|
||||
WHERE o.shop_id = ? AND o.id IN ?
|
||||
AND ABS(o.sale_total - (SELECT COALESCE(SUM(i.sale_amount),0)
|
||||
FROM stock_out_items i WHERE i.order_id = o.id)) > 0.01`,
|
||||
shopID, ids).Scan(&st.TotalMismatches).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !apply {
|
||||
return errDryRun
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, errDryRun) {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
shopCode = flag.String("shop-code", "", "目标门店 code(与 shop-id 二选一)")
|
||||
shopID = flag.Uint64("shop-id", 0, "目标门店 id(与 shop-code 二选一)")
|
||||
apply = flag.Bool("apply", false, "真正落库(缺省 dry-run:试算后回滚)")
|
||||
envFile = flag.String("env-file", "", "可选:systemd 风格 KEY=VALUE 环境文件(读取 DATABASE_DSN)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *envFile != "" {
|
||||
loadEnvFile(*envFile)
|
||||
}
|
||||
config.Load()
|
||||
db, err := gorm.Open(mysql.Open(config.C.Database.DSN), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
|
||||
var shop model.Shop
|
||||
switch {
|
||||
case *shopID > 0:
|
||||
if err := db.First(&shop, *shopID).Error; err != nil {
|
||||
log.Fatalf("门店 id=%d 不存在: %v", *shopID, err)
|
||||
}
|
||||
case *shopCode != "":
|
||||
if err := db.Where("code = ?", *shopCode).First(&shop).Error; err != nil {
|
||||
log.Fatalf("门店 code=%s 不存在: %v", *shopCode, err)
|
||||
}
|
||||
default:
|
||||
log.Fatal("必须指定 --shop-code 或 --shop-id")
|
||||
}
|
||||
|
||||
st, err := fixShop(db, shop.ID, *apply)
|
||||
if err != nil {
|
||||
log.Fatalf("修复失败(已回滚,未改动任何数据): %v", err)
|
||||
}
|
||||
|
||||
mode := "*** DRY-RUN(已回滚,未写库)***"
|
||||
if *apply {
|
||||
mode = "已落库"
|
||||
}
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println(" 历史出库价格错列修复汇总 —— " + mode)
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Printf(" 门店:%s (id=%d)\n", shop.Name, shop.ID)
|
||||
fmt.Printf(" 修复明细行(售价←原成本列):%d\n", st.Items)
|
||||
fmt.Printf(" 成本回填:入库价 %d / 库存价 %d / 商品进价 %d / 查无(成本待定) %d\n",
|
||||
st.CostFromStockIn, st.CostFromInventory, st.CostFromProduct, st.CostUnmatched)
|
||||
fmt.Printf(" 涉及出库单(已重算利润):%d\n", st.Orders)
|
||||
if len(st.TotalMismatches) > 0 {
|
||||
fmt.Printf(" ⚠ 应收合计与明细不一致的单(未改动,需人工核):%v\n", st.TotalMismatches)
|
||||
} else {
|
||||
fmt.Println(" 应收合计校验:全部一致 ✓")
|
||||
}
|
||||
}
|
||||
|
||||
func loadEnvFile(path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Fatalf("读取 env 文件 %s 失败: %v", path, err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
func f64(v float64) *float64 { return &v }
|
||||
|
||||
// fixture 复刻线上错列形态:
|
||||
// - 占位商品 HIST-PLACEHOLDER,历史出库明细 product_id 均指向它
|
||||
// - 明细 cost_price/cost_amount 实为售价口径(源「单价/金额」),sale_* = 0
|
||||
// - 单头 sale_total = 源合计(售价口径,与明细"成本"合计相等)
|
||||
//
|
||||
// 四个成本回填场景:A=入库明细价 B=库存快照价 D=商品进价 C=查无。
|
||||
type fixture struct {
|
||||
db *gorm.DB
|
||||
shopID uint64
|
||||
placeholder *model.Product
|
||||
order1 *model.StockOutOrder // A/B/C 三行,sale_total 与明细一致
|
||||
order2 *model.StockOutOrder // D 一行,sale_total 故意不一致
|
||||
normalItem *model.StockOutItem // 非占位商品的正常明细,不得被动到
|
||||
}
|
||||
|
||||
func setupFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "FIX01")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "主仓")
|
||||
|
||||
// 复刻线上形态:占位商品已被界面软删,明细仍引用其 id,工具必须照常定位
|
||||
ph := &model.Product{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: placeholderProductCode, Name: "历史导入占位"}
|
||||
require.NoError(t, db.Create(ph).Error)
|
||||
require.NoError(t, db.Exec(
|
||||
"UPDATE products SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?", ph.ID).Error)
|
||||
|
||||
// A:入库明细里有真实进价 13600(同时给商品主数据一个更低优先级的进价,验证①优先)
|
||||
prodA := &model.Product{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: "ZXZ027628", Name: "茅台铁盖1995五星", PublicID: "pub-a", PurchasePrice: 1}
|
||||
require.NoError(t, db.Create(prodA).Error)
|
||||
sin := &model.StockInOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "RKTEST1", WarehouseID: wh.ID, OperatorID: 1, Status: "approved"}
|
||||
require.NoError(t, db.Create(sin).Error)
|
||||
require.NoError(t, db.Create(&model.StockInItem{
|
||||
OrderID: sin.ID, ShopID: shop.ID, ProductID: ph.ID,
|
||||
ProductCode: "ZXZ027628", Quantity: 1, CostPrice: 13600, CostAmount: 13600,
|
||||
}).Error)
|
||||
|
||||
// B:只有库存快照价 500
|
||||
require.NoError(t, db.Create(&model.Inventory{
|
||||
ShopID: shop.ID, ProductCode: "B001", Quantity: 2, UnitPrice: f64(500),
|
||||
}).Error)
|
||||
|
||||
// D:只有商品主数据进价 1500
|
||||
prodD := &model.Product{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: "D001", Name: "商品D", PublicID: "pub-d", PurchasePrice: 1500}
|
||||
require.NoError(t, db.Create(prodD).Error)
|
||||
|
||||
// 出库单 1:A(14300×1) + B(400×2) + C(999×1),sale_total 与明细合计一致
|
||||
o1 := &model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "CKTEST1", WarehouseID: wh.ID, OperatorID: 1, Status: "approved",
|
||||
SaleTotal: 14300 + 800 + 999}
|
||||
require.NoError(t, db.Create(o1).Error)
|
||||
for _, it := range []*model.StockOutItem{
|
||||
{OrderID: o1.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "ZXZ027628",
|
||||
Quantity: 1, CostPrice: 14300, CostAmount: 14300},
|
||||
{OrderID: o1.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "B001",
|
||||
Quantity: 2, CostPrice: 400, CostAmount: 800},
|
||||
{OrderID: o1.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "C001",
|
||||
Quantity: 1, CostPrice: 999, CostAmount: 999},
|
||||
} {
|
||||
require.NoError(t, db.Create(it).Error)
|
||||
}
|
||||
|
||||
// 出库单 2:D(2000×1),sale_total 故意与明细不一致(源数据脏),应只报不改
|
||||
o2 := &model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "CKTEST2", WarehouseID: wh.ID, OperatorID: 1, Status: "pending",
|
||||
SaleTotal: 123}
|
||||
require.NoError(t, db.Create(o2).Error)
|
||||
require.NoError(t, db.Create(&model.StockOutItem{
|
||||
OrderID: o2.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "D001",
|
||||
Quantity: 1, CostPrice: 2000, CostAmount: 2000,
|
||||
}).Error)
|
||||
|
||||
// 正常单(非占位商品):sale_price=0 且 cost>0,但 product_id 非占位 → 不得被动
|
||||
prodN := testutil.CreateTestProduct(db, shop.ID, "正常商品")
|
||||
o3 := &model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "CKTEST3", WarehouseID: wh.ID, OperatorID: 1, Status: "approved",
|
||||
SaleTotal: 0}
|
||||
require.NoError(t, db.Create(o3).Error)
|
||||
normal := &model.StockOutItem{OrderID: o3.ID, ShopID: shop.ID, ProductID: prodN.ID,
|
||||
ProductCode: "P-正常商品", Quantity: 1, CostPrice: 100, CostAmount: 100}
|
||||
require.NoError(t, db.Create(normal).Error)
|
||||
|
||||
return &fixture{db: db, shopID: shop.ID, placeholder: ph,
|
||||
order1: o1, order2: o2, normalItem: normal}
|
||||
}
|
||||
|
||||
func TestFixShop_DryRunDoesNotWrite(t *testing.T) {
|
||||
fx := setupFixture(t)
|
||||
|
||||
st, err := fixShop(fx.db, fx.shopID, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, st.Items)
|
||||
assert.Equal(t, 1, st.CostFromStockIn)
|
||||
assert.Equal(t, 1, st.CostFromInventory)
|
||||
assert.Equal(t, 1, st.CostFromProduct)
|
||||
assert.Equal(t, 1, st.CostUnmatched)
|
||||
assert.Equal(t, 2, st.Orders)
|
||||
assert.Equal(t, []string{"CKTEST2"}, st.TotalMismatches)
|
||||
|
||||
// 回滚后数据原样
|
||||
var it model.StockOutItem
|
||||
require.NoError(t, fx.db.Where("product_code = ?", "ZXZ027628").
|
||||
Where("order_id = ?", fx.order1.ID).First(&it).Error)
|
||||
assert.Equal(t, 14300.0, it.CostPrice)
|
||||
assert.Equal(t, 0.0, it.SalePrice)
|
||||
var o model.StockOutOrder
|
||||
require.NoError(t, fx.db.First(&o, fx.order1.ID).Error)
|
||||
assert.Equal(t, 0.0, o.ProfitTotal)
|
||||
}
|
||||
|
||||
func TestFixShop_Apply(t *testing.T) {
|
||||
fx := setupFixture(t)
|
||||
|
||||
st, err := fixShop(fx.db, fx.shopID, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, st.Items)
|
||||
|
||||
get := func(orderID uint64, code string) model.StockOutItem {
|
||||
var it model.StockOutItem
|
||||
require.NoError(t, fx.db.Where("order_id = ? AND product_code = ?", orderID, code).
|
||||
First(&it).Error)
|
||||
return it
|
||||
}
|
||||
|
||||
// A:售价归位 14300,成本回填入库价 13600
|
||||
a := get(fx.order1.ID, "ZXZ027628")
|
||||
assert.Equal(t, 14300.0, a.SalePrice)
|
||||
assert.Equal(t, 14300.0, a.SaleAmount)
|
||||
assert.Equal(t, 13600.0, a.CostPrice)
|
||||
assert.Equal(t, 13600.0, a.CostAmount)
|
||||
|
||||
// B:成本回填库存快照价 500,小计 = 2×500
|
||||
b := get(fx.order1.ID, "B001")
|
||||
assert.Equal(t, 400.0, b.SalePrice)
|
||||
assert.Equal(t, 800.0, b.SaleAmount)
|
||||
assert.Equal(t, 500.0, b.CostPrice)
|
||||
assert.Equal(t, 1000.0, b.CostAmount)
|
||||
|
||||
// C:查无进价 → 成本待定 0
|
||||
c := get(fx.order1.ID, "C001")
|
||||
assert.Equal(t, 999.0, c.SalePrice)
|
||||
assert.Equal(t, 0.0, c.CostPrice)
|
||||
assert.Equal(t, 0.0, c.CostAmount)
|
||||
|
||||
// D:成本回填商品进价 1500
|
||||
d := get(fx.order2.ID, "D001")
|
||||
assert.Equal(t, 2000.0, d.SalePrice)
|
||||
assert.Equal(t, 1500.0, d.CostPrice)
|
||||
|
||||
// 单头:利润按 recalcStockOutProfit 口径;sale_total 不动
|
||||
var o1, o2 model.StockOutOrder
|
||||
require.NoError(t, fx.db.First(&o1, fx.order1.ID).Error)
|
||||
// (14300-13600)×1 + (400-500)×2 + (999-0)×1 = 700 - 200 + 999
|
||||
assert.InDelta(t, 1499.0, o1.ProfitTotal, 0.001)
|
||||
assert.Equal(t, 16099.0, o1.SaleTotal)
|
||||
require.NoError(t, fx.db.First(&o2, fx.order2.ID).Error)
|
||||
assert.InDelta(t, 500.0, o2.ProfitTotal, 0.001)
|
||||
assert.Equal(t, 123.0, o2.SaleTotal) // 不一致只报不改
|
||||
assert.Equal(t, []string{"CKTEST2"}, st.TotalMismatches)
|
||||
|
||||
// 正常明细未被波及
|
||||
var n model.StockOutItem
|
||||
require.NoError(t, fx.db.First(&n, fx.normalItem.ID).Error)
|
||||
assert.Equal(t, 100.0, n.CostPrice)
|
||||
assert.Equal(t, 0.0, n.SalePrice)
|
||||
|
||||
// 幂等:重跑无待修行
|
||||
st2, err := fixShop(fx.db, fx.shopID, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, st2.Items)
|
||||
assert.Equal(t, 0, st2.Orders)
|
||||
}
|
||||
|
||||
func TestFixShop_NoPlaceholder(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "FIX02")
|
||||
_, err := fixShop(db, shop.ID, false)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -529,13 +529,17 @@ func (ic *importCtx) writeOrder(
|
||||
TenantBase: model.TenantBase{ShopID: ic.shopID},
|
||||
OrderNo: orderNo, Type: orderType, WarehouseID: ic.whID,
|
||||
PartnerID: partnerID, OperatorID: operatorID, CreatorID: creatorID,
|
||||
// 忠实导入:源「金额」合计沿旧行为落应收合计(sale_price=0 待定价)
|
||||
// 忠实导入:源「金额」合计是售价口径,落应收合计(与明细 sale_* 同口径)
|
||||
ReviewerID: reviewerID, Status: status, OrderDate: orderDate, SaleTotal: totalAmount,
|
||||
}
|
||||
if err := tx.Create(&o).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dr := range details {
|
||||
// 源出库明细「单价/金额」是售价口径(卖给客户的价,应收侧),落 sale 列;
|
||||
// 真实成本导入时不可知,留 0(成本待定),事后用 cmd/fix-history-prices
|
||||
// 按商品编号回查入库价/库存价/商品进价回填。
|
||||
// (2026-07-10 修正:此前误落 cost_price/cost_amount,线上数据已用同工具修复)
|
||||
it := model.StockOutItem{
|
||||
OrderID: o.ID, ShopID: ic.shopID, ProductID: ic.placeholderProductID,
|
||||
ProductCode: get(dr, dCols, "商品编号"),
|
||||
@@ -543,8 +547,8 @@ func (ic *importCtx) writeOrder(
|
||||
Series: get(dr, dCols, "系列"),
|
||||
Spec: get(dr, dCols, "规格"),
|
||||
Quantity: parseFloatLoose(get(dr, dCols, "数量")),
|
||||
CostPrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
CostAmount: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
SalePrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
SaleAmount: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
BatchNo: get(dr, dCols, "批次号"),
|
||||
}
|
||||
it.ProductionDate = parseDatePtr(get(dr, dCols, "生产日期"))
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>{{.PageTitle}}</title>{{.OGTags}}
|
||||
<style>
|
||||
/* 设计系统单一真源:变量名与三主题(a 经典蓝浅 / b 琥珀深 / c 酒窖暖浅)色值
|
||||
逐字取自 .superpowers/prototype/tokens.css。SSR 页自包含无法直接引 tokens.css,
|
||||
逐字取自 design/prototype/tokens.css。SSR 页自包含无法直接引 tokens.css,
|
||||
改配色须先改原型令牌再同步此处。--glow/--seal-bg 为本页派生变量(主色低透明度/表面高透明度)。 */
|
||||
:root{
|
||||
--r-md:6px; --r-lg:10px; --r-xl:14px; --r-pill:999px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.PageTitle}}</title>{{.OGTags}}
|
||||
<style>
|
||||
/* 设计系统单一真源:变量名与三主题(a/b/c)色值逐字取自 .superpowers/prototype/tokens.css。
|
||||
/* 设计系统单一真源:变量名与三主题(a/b/c)色值逐字取自 design/prototype/tokens.css。
|
||||
与商品公开页同族;改配色须先改原型令牌再同步此处。--glow 为本页派生变量(主色低透明度)。 */
|
||||
:root{
|
||||
--r-md:6px; --r-lg:10px; --r-xl:14px; --r-pill:999px;
|
||||
|
||||
@@ -14,6 +14,8 @@ class Product {
|
||||
final double? purchasePrice;
|
||||
final double? salePrice;
|
||||
final int? minStock;
|
||||
// 生产日期(YYYY-MM-DD,后端 Date 序列化,空串=未填)
|
||||
final String? productionDate;
|
||||
final String? description;
|
||||
final String? remark;
|
||||
final Map<String, dynamic>? customFields;
|
||||
@@ -41,6 +43,7 @@ class Product {
|
||||
this.purchasePrice,
|
||||
this.salePrice,
|
||||
this.minStock,
|
||||
this.productionDate,
|
||||
this.description,
|
||||
this.remark,
|
||||
this.customFields,
|
||||
@@ -75,6 +78,7 @@ class Product {
|
||||
minStock: json['min_stock'] != null
|
||||
? (json['min_stock'] as num).toInt()
|
||||
: null,
|
||||
productionDate: json['production_date'] as String?,
|
||||
description: json['description'] as String?,
|
||||
remark: json['remark'] as String?,
|
||||
customFields: json['custom_fields'] as Map<String, dynamic>?,
|
||||
|
||||
@@ -613,6 +613,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
return MCard(
|
||||
onTap: item.productId != null ? () => _openEditor(item) : null,
|
||||
nm: item.productName.isEmpty ? '-' : item.productName,
|
||||
// 生产日期小字缀在品名后(原型 .mc-date,2026-07-10 用户口径)
|
||||
nmSuffix: (item.productionDate ?? '').isEmpty ? null : item.productionDate,
|
||||
sub: sub.isEmpty ? null : sub,
|
||||
badges: [DsTextIconBadge(status)],
|
||||
amtWidget: Text.rich(
|
||||
|
||||
@@ -523,38 +523,5 @@ class DsSortHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 原型 thead th .fh 漏斗列头:label + funnel(12, sw1.6),有筛选值时整体 primary。
|
||||
/// 点击回调携带列头自身 BuildContext(供 showDsMenu 锚定)。
|
||||
class DsFilterHeader extends StatelessWidget {
|
||||
final String label;
|
||||
final bool filtered;
|
||||
final void Function(BuildContext anchorContext) onOpen;
|
||||
const DsFilterHeader(
|
||||
{super.key,
|
||||
required this.label,
|
||||
required this.filtered,
|
||||
required this.onOpen});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final color = filtered ? t.primary : t.muted;
|
||||
return Builder(
|
||||
builder: (bctx) => InkWell(
|
||||
onTap: () => onOpen(bctx),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color)),
|
||||
const SizedBox(width: 5),
|
||||
Icon(LucideIcons.funnel, size: 12, color: color),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// DsFilterHeader(列头漏斗筛选)已于 2026-07-07 退役删除:筛选统一上移工具栏
|
||||
// chips(用户口径,形式参照出库管理),列头仅保留 DsSortHeader 排序。
|
||||
|
||||
@@ -101,6 +101,9 @@ class MCard extends StatelessWidget {
|
||||
/// mc-nm 标题(600 heading)。
|
||||
final String nm;
|
||||
|
||||
/// mc-date 标题后缀(原型 .mc-nm .mc-date:xs muted mono,左距 8);null 不渲染。
|
||||
final String? nmSuffix;
|
||||
|
||||
/// mc-sub 副行(xs muted mono);null 不渲染。
|
||||
final String? sub;
|
||||
|
||||
@@ -119,6 +122,7 @@ class MCard extends StatelessWidget {
|
||||
const MCard({
|
||||
super.key,
|
||||
required this.nm,
|
||||
this.nmSuffix,
|
||||
this.sub,
|
||||
this.badges = const [],
|
||||
this.amt,
|
||||
@@ -151,13 +155,32 @@ class MCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(nm,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.heading)),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(nm,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.heading)),
|
||||
),
|
||||
if (nmSuffix != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(nmSuffix!,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsXs,
|
||||
color: t.muted,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback:
|
||||
AppFonts.monoFallback)),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (sub != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(sub!,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/theme/context_tokens.dart';
|
||||
|
||||
// ⚠️ LEGACY(2026-07-07 标注):本组件为旧移动卡片范式(label:value 竖排字段),
|
||||
// 新范式 = widgets/ds/m_card.dart 的 MCard(镜像原型 mobile-atoms .m-card,
|
||||
// 图标徽章 + 双栏紧凑)。**新屏禁用本组件,一律用 MCard**;存量 6 屏
|
||||
// (出入库录单/往来/盘点/基础数据/财务)迁移已登记 todo,迁完删除本文件。
|
||||
|
||||
/// 移动端列表卡片的单个字段(label: value)。
|
||||
class MobileCardField {
|
||||
final String label;
|
||||
|
||||
@@ -394,6 +394,9 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
|
||||
if ((p.series ?? '').isNotEmpty) rows.add(('系列', b(p.series!)));
|
||||
if ((p.spec ?? '').isNotEmpty) rows.add(('规格', b(p.spec!)));
|
||||
if ((p.categoryName ?? '').isNotEmpty) rows.add(('分类', b(p.categoryName!)));
|
||||
if ((p.productionDate ?? '').isNotEmpty) {
|
||||
rows.add(('生产日期', b(p.productionDate!, mono: true)));
|
||||
}
|
||||
if (widget.qty != null) {
|
||||
rows.add((
|
||||
'当前库存',
|
||||
|
||||
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 393 KiB After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 389 KiB After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 434 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 386 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 439 KiB After Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 388 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 354 KiB After Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 388 KiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 401 KiB After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 218 KiB |
@@ -11,11 +11,11 @@
|
||||
|
||||
| 项 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| token 单源 | `.superpowers/prototype/tokens.css` | 三主题 `[data-theme=a/b/c]`,已 snapshot 到 `client/lib/core/theme/token_source/tokens.css` |
|
||||
| token 单源 | `design/prototype/tokens.css` | 三主题 `[data-theme=a/b/c]`,已 snapshot 到 `client/lib/core/theme/token_source/tokens.css` |
|
||||
| token codegen | `client/lib/core/theme/tool/gen_tokens.mjs` → `app_tokens.g.dart` | 单源生成,**禁手改**;`kTokensA/B/C` + `appTokensOf(key)` |
|
||||
| 实现层 | `themes.dart`(buildTheme)+ `context_tokens.dart`(`context.tokens`) | AppTokens ThemeExtension 挂三 ThemeData |
|
||||
| 原型清单 | `.superpowers/prototype/screens/*.html` | 桌面 + `m-*` 移动;viewport 桌面 1280×860 / 移动 390×844 |
|
||||
| 设计铁律 | `.superpowers/prototype/index.html`(atoms 注册)+ `tools/check-ds.mjs` 闸 | 颜色只走 token;组件出自已登记 atoms |
|
||||
| 原型清单 | `design/prototype/screens/*.html` | 桌面 + `m-*` 移动;viewport 桌面 1280×860 / 移动 390×844 |
|
||||
| 设计铁律 | `design/prototype/index.html`(atoms 注册)+ `tools/check-ds.mjs` 闸 | 颜色只走 token;组件出自已登记 atoms |
|
||||
| 验收工具 | `~/.claude/skills/design-distill/tools/{shoot-prototype,diff}.mjs` | prototype shoot 已验证三主题可出图 |
|
||||
|
||||
### 主题
|
||||
@@ -206,7 +206,7 @@ AppTokens 目前**仅颜色**。原型还驱动:
|
||||
> - 关于:旧版 帮助文档/扫码防伪/系统信息 卡删除,构建号并入版本 cell;更新日志走 `/public/release`(与官网时间线同源),接口不可达回退只显当前版本。
|
||||
|
||||
**验收法(HTML→Flutter 特例)**:HTML(Chromium) 与 Flutter 渲染器不同,**严格 0.1% pixelmatch 不适用跨渲染器**(见 design-distill `references/flutter-golden.md`)。本项目双闸:
|
||||
1. **保真闸(人工目检)**:`node tools/ds-compare.mjs --html .superpowers/prototype/screens/<screen>.html --prefix <prefix>` → 截原型基准 + 与 Flutter golden 用 montage 并排 → `design/_compare/`。
|
||||
1. **保真闸(人工目检)**:`node tools/ds-compare.mjs --html design/prototype/screens/<screen>.html --prefix <prefix>` → 截原型基准 + 与 Flutter golden 用 montage 并排 → `design/_compare/`。
|
||||
2. **回归闸(自动)**:`test/golden/<screen>_golden_test.dart` 走 `pumpGolden`/`goldenAcrossThemes`(`test/support/golden_harness.dart`)出 A/B/C 三主题 golden,入库随 `flutter test` 同渲染器自比,抓串色/漏 token。
|
||||
3. token 残留闸:`grep 0xFF…` 该屏为 0(颜色全走 `context.tokens`)。
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@
|
||||
<section class="section" id="m-comp">
|
||||
<h2>移动专属组件 .m-*</h2><p class="desc">来自 <code>mobile-atoms.css</code> 的移动专属原子,由 <code>mobile-shell.js</code> 装配外壳。<b>复用上方公用原子</b>(按钮/徽章/分段/输入),此处只展示移动新增的 <code>.m-*</code>。</p>
|
||||
<div style="margin:0 0 16px"><a class="btn ghost" href="screens/mobile-kit.html"><svg viewBox="0 0 24 24"><use href="#i-ic62"/></svg>打开移动 UI Kit · 全部 .m-* 实机展示</a></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>移动壳 + 列表(实机预览)</h3><span class="tk">.m-top · .m-tabbar · .m-page · .m-kpi · .m-search · .m-filters/.m-pill · .m-card/.m-row · .m-fab</span></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>移动壳 + 列表(实机预览)</h3><span class="tk">.m-top · .m-tabbar · .m-page · .m-kpi · .m-search · .m-filters/.m-pill · .m-card/.m-row(.mc-nm 可带 .mc-date 日期后缀)· .m-fab</span></div>
|
||||
<div class="pcard-b" style="align-items:flex-start"><div class="mdemo">
|
||||
<div class="mphone">
|
||||
<div class="m-top"><div class="m-title">库存</div><div class="m-actions"><div class="m-ic"><svg viewBox="0 0 24 24"><use href="#i-shirt"/></svg></div><div class="m-ic"><svg viewBox="0 0 24 24"><use href="#i-bell"/></svg><span class="m-dot">3</span></div><div class="m-av">王</div></div></div>
|
||||
@@ -451,7 +451,7 @@
|
||||
<div class="m-search" style="margin-top:12px"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input placeholder="商品名 / 拼音 / 编码"></div>
|
||||
<div class="m-filters"><div class="m-pill on">全部</div><div class="m-pill">酱香</div><div class="m-pill">浓香</div><div class="m-pill">清香</div></div>
|
||||
<div class="m-section">商品 · 共 10</div>
|
||||
<a class="m-card"><div class="m-row"><div class="mc-main"><div class="mc-nm">茅台 飞天 53°</div><div class="mc-sub">MT-FT-500 · 500ml×6</div></div><div class="mc-right"><span class="badge b-在售">在售</span><span class="mc-amt">128 件</span></div><div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div></div></a>
|
||||
<a class="m-card"><div class="m-row"><div class="mc-main"><div class="mc-nm">茅台 飞天 53°<span class="mc-date">2024-07-11</span></div><div class="mc-sub">MT-FT-500 · 500ml×6</div></div><div class="mc-right"><span class="badge b-在售">在售</span><span class="mc-amt">128 件</span></div><div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div></div></a>
|
||||
<a class="m-card"><div class="m-row"><div class="mc-main"><div class="mc-nm">剑南春 水晶剑</div><div class="mc-sub">JNC-SJ-500 · 500ml×6</div></div><div class="mc-right"><span class="badge b-缺货">缺货</span><span class="mc-amt">8 件</span></div><div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div></div></a>
|
||||
</div></div>
|
||||
<nav class="m-tabbar">
|
||||
@@ -42,6 +42,7 @@
|
||||
.m-row{display:flex; align-items:center; gap:12px;}
|
||||
.m-row .mc-main{min-width:0; flex:1;}
|
||||
.m-row .mc-nm{font-weight:600; color:var(--heading); font-size:var(--fs-body); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
||||
.m-row .mc-nm .mc-date{font-weight:400; font-size:var(--fs-xs); color:var(--muted); font-family:var(--font-mono); margin-left:8px;}
|
||||
.m-row .mc-sub{font-size:var(--fs-xs); color:var(--muted); margin-top:3px; font-family:var(--font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
||||
.m-row .mc-right{display:flex; flex-direction:column; align-items:flex-end; gap:5px; flex:none;}
|
||||
.m-row .mc-amt{font-weight:700; color:var(--heading); font-size:var(--fs-body); font-family:var(--font-mono);}
|
||||
@@ -36,16 +36,16 @@
|
||||
<script src="mobile-shell.js"></script>
|
||||
<script>
|
||||
const ITEMS=[
|
||||
{name:'茅台 飞天 53°',code:'MT-FT-500',spec:'500ml×6',series:'飞天',batch:'PZ240711',qty:128,unit:'件',cost:'¥2,680',supplier:'贵州茅台经销',status:'在售'},
|
||||
{name:'五粮液 普五 52°',code:'WLY-PW-500',spec:'500ml×6',series:'普五',batch:'PZ240428',qty:64,unit:'件',cost:'¥1,050',supplier:'宜宾五粮液',status:'在售'},
|
||||
{name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',qty:8,unit:'件',cost:'¥438',supplier:'绵竹剑南春',status:'已售'},
|
||||
{name:'国窖 1573',code:'GJ-1573-500',spec:'500ml×6',series:'1573',batch:'PZ240208',qty:42,unit:'件',cost:'¥980',supplier:'泸州老窖',status:'在售'},
|
||||
{name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'550ml×6',series:'梦之蓝M6',batch:'PZ240520',qty:23,unit:'件',cost:'¥620',supplier:'洋河股份',status:'库存'},
|
||||
{name:'泸州老窖 特曲',code:'LZLJ-TQ-500',spec:'500ml×6',series:'特曲',batch:'PZ240630',qty:156,unit:'件',cost:'¥320',supplier:'泸州老窖',status:'在售'},
|
||||
{name:'习酒 窖藏 1988',code:'XJ-JC1988',spec:'500ml×6',series:'窖藏1988',batch:'PZ230918',qty:31,unit:'件',cost:'¥760',supplier:'贵州习酒',status:'在售'},
|
||||
{name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',qty:0,unit:'件',cost:'¥420',supplier:'山西杏花村',status:'已售'},
|
||||
{name:'郎酒 红花郎 15',code:'LJ-HHL15',spec:'500ml×6',series:'红花郎15',batch:'PZ240325',qty:54,unit:'件',cost:'¥560',supplier:'四川郎酒',status:'在售'},
|
||||
{name:'西凤酒 旗舰版',code:'XF-QJ-500',spec:'500ml×6',series:'旗舰版',batch:'PZ240614',qty:88,unit:'件',cost:'¥280',supplier:'陕西西凤',status:'在售'},
|
||||
{name:'茅台 飞天 53°',code:'MT-FT-500',spec:'500ml×6',series:'飞天',batch:'PZ240711',prod:'2024-07-11',qty:128,unit:'件',cost:'¥2,680',supplier:'贵州茅台经销',status:'在售'},
|
||||
{name:'五粮液 普五 52°',code:'WLY-PW-500',spec:'500ml×6',series:'普五',batch:'PZ240428',prod:'2024-04-28',qty:64,unit:'件',cost:'¥1,050',supplier:'宜宾五粮液',status:'在售'},
|
||||
{name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',prod:'2023-11-15',qty:8,unit:'件',cost:'¥438',supplier:'绵竹剑南春',status:'已售'},
|
||||
{name:'国窖 1573',code:'GJ-1573-500',spec:'500ml×6',series:'1573',batch:'PZ240208',prod:'2024-02-08',qty:42,unit:'件',cost:'¥980',supplier:'泸州老窖',status:'在售'},
|
||||
{name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'550ml×6',series:'梦之蓝M6',batch:'PZ240520',prod:'2024-05-20',qty:23,unit:'件',cost:'¥620',supplier:'洋河股份',status:'库存'},
|
||||
{name:'泸州老窖 特曲',code:'LZLJ-TQ-500',spec:'500ml×6',series:'特曲',batch:'PZ240630',prod:'2024-06-30',qty:156,unit:'件',cost:'¥320',supplier:'泸州老窖',status:'在售'},
|
||||
{name:'习酒 窖藏 1988',code:'XJ-JC1988',spec:'500ml×6',series:'窖藏1988',batch:'PZ230918',prod:'2023-09-18',qty:31,unit:'件',cost:'¥760',supplier:'贵州习酒',status:'在售'},
|
||||
{name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',prod:'2024-01-12',qty:0,unit:'件',cost:'¥420',supplier:'山西杏花村',status:'已售'},
|
||||
{name:'郎酒 红花郎 15',code:'LJ-HHL15',spec:'500ml×6',series:'红花郎15',batch:'PZ240325',prod:'2024-03-25',qty:54,unit:'件',cost:'¥560',supplier:'四川郎酒',status:'在售'},
|
||||
{name:'西凤酒 旗舰版',code:'XF-QJ-500',spec:'500ml×6',series:'旗舰版',batch:'PZ240614',prod:'2024-06-14',qty:88,unit:'件',cost:'¥280',supplier:'陕西西凤',status:'在售'},
|
||||
];
|
||||
const state={q:''};
|
||||
|
||||
@@ -57,7 +57,7 @@ function render(){
|
||||
if(!rows.length){ L.innerHTML=`<div class="m-empty"><svg viewBox="0 0 24 24"><use href="#i-box"/></svg>没有匹配的商品 · 调整筛选或搜索</div>`; return; }
|
||||
L.innerHTML=rows.map((it,i)=>`<a class="m-card" onclick="openItem(${ITEMS.indexOf(it)})">
|
||||
<div class="m-row">
|
||||
<div class="mc-main"><div class="mc-nm">${it.name}</div><div class="mc-sub">${it.code} · ${it.spec} · ${it.cost}</div></div><!-- 单价=成本进价,仅管理员可见(2026-07-06) -->
|
||||
<div class="mc-main"><div class="mc-nm">${it.name}<span class="mc-date">${it.prod}</span></div><div class="mc-sub">${it.code} · ${it.spec} · ${it.cost}</div></div><!-- 单价=成本进价,仅管理员可见(2026-07-06) -->
|
||||
<div class="mc-right"><span class="badge b-${it.status}">${it.status}</span><span class="mc-amt"><span class="${it.qty<=10?'qty-low':''}">${it.qty}</span> ${it.unit}</span></div>
|
||||
<div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@ function render(){
|
||||
function openItem(idx){ const it=ITEMS[idx];
|
||||
if(!peEdit||peEdit.idx!==idx) peEdit={idx,imgs:[PE_SAMPLE[0],'',''],introName:'',introBody:''};
|
||||
const hasIntro=!!peEdit.introName;
|
||||
openSheet(it.name, `<div class="drow"><span>编码</span><b style="font-family:var(--font-mono)">${it.code}</b></div><div class="drow"><span>规格</span><b>${it.spec}</b></div><div class="drow"><span>系列</span><b>${it.series}</b></div><div class="drow"><span>批次号</span><b style="font-family:var(--font-mono)">${it.batch}</b></div><div class="drow"><span>当前库存</span><b>${it.qty} ${it.unit}</b></div><div class="drow"><span>成本价</span><b>${it.cost}</b></div><div class="drow"><span>供应商</span><b>${it.supplier}</b></div><div class="drow" style="border-bottom:none"><span>状态</span><b style="display:flex;align-items:center;gap:10px"><span class="badge b-${it.status}">${it.status}</span>${it.status==='已售'?'':`<button class="switch ${it.status==='在售'?'on':''}" aria-label="挂售切换" onclick="this.classList.toggle('on');const b=this.previousElementSibling;const on=this.classList.contains('on');b.textContent=on?'在售':'库存';b.className='badge '+(on?'b-在售':'b-库存');"></button>`}</b></div>
|
||||
openSheet(it.name, `<div class="drow"><span>编码</span><b style="font-family:var(--font-mono)">${it.code}</b></div><div class="drow"><span>规格</span><b>${it.spec}</b></div><div class="drow"><span>系列</span><b>${it.series}</b></div><div class="drow"><span>批次号</span><b style="font-family:var(--font-mono)">${it.batch}</b></div><div class="drow"><span>生产日期</span><b style="font-family:var(--font-mono)">${it.prod}</b></div><div class="drow"><span>当前库存</span><b>${it.qty} ${it.unit}</b></div><div class="drow"><span>成本价</span><b>${it.cost}</b></div><div class="drow"><span>供应商</span><b>${it.supplier}</b></div><div class="drow" style="border-bottom:none"><span>状态</span><b style="display:flex;align-items:center;gap:10px"><span class="badge b-${it.status}">${it.status}</span>${it.status==='已售'?'':`<button class="switch ${it.status==='在售'?'on':''}" aria-label="挂售切换" onclick="this.classList.toggle('on');const b=this.previousElementSibling;const on=this.classList.contains('on');b.textContent=on?'在售':'库存';b.className='badge '+(on?'b-在售':'b-库存');"></button>`}</b></div>
|
||||
<div class="pe-card" style="margin-top:14px"><div class="pe-h"><span class="pe-ic"><svg viewBox="0 0 24 24"><use href="#i-ic27"/></svg></span>商品图片</div>
|
||||
<div class="pe-b"><div class="pe-gallery" id="mGallery"></div><div class="pe-note">建议主图为白底产品图,可再加细节图(瓶身/防伪)与场景图。</div></div></div>
|
||||
<div class="pe-card"><div class="pe-h"><span class="pe-ic"><svg viewBox="0 0 24 24"><use href="#i-ic28"/></svg></span>商品介绍 / 卖点</div>
|
||||