Compare commits

..

4 Commits

Author SHA1 Message Date
wangjia 31bf645c30 chore: release server-v1.1.7
Design Source Checks / design-source (push) Successful in 16s
Deploy Server / release-deploy-server (push) Successful in 1m3s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:33:17 +08:00
wangjia 3573f015c3 fix(client): 出入库录单 golden 随日期漂移——表单支持钉死单据日期
根因:表单 _orderDate 默认 DateTime.now(),golden 07-04 录制后每天必挂
(整屏 diff 只有日期格一个数字)。表单加 initialDate 测试注入口,
golden 测试钉死 2026-07-04,基准图零重录、永不随日期漂移。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:26 +08:00
wangjia d9f1f6956e feat(client): 库存卡片显示单价(进价)+ 成本按角色隐藏
- 手机库存卡副行追加进价(编号 · 规格 · ¥xxx),仅管理员可见
- 宽屏成本价/总价列、货值 KPI、商品抽屉成本行对非管理员隐藏(isAdminProvider)
- m-inventory 原型同步 + 移动 golden ×6 重录(管理员形态基准)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:15 +08:00
wangjia 75d0accb14 feat(backend): 库存成本仅管理员可见——List 抹空 unit_price、Summary 抹零货值
与出库 stripStockOutCost 同口径:operator/readonly 服务端兜底抹除,
防抓包看到成本;sale_price 与数量等字段不受影响。CLAUDE.md 口径同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:05 +08:00
19 changed files with 117 additions and 19 deletions
@@ -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}</div></div>
<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-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>
+6
View File
@@ -5,6 +5,12 @@
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.7] - 2026-07-06
### 改进
- 库存成本仅管理员可见:库存列表不再向操作员/只读账号返回进价,库存货值
汇总同步隐藏(与出库单成本可见性同一口径,防止非管理员通过接口看到成本)。
## [1.1.6] - 2026-07-05
### 新功能
+1 -1
View File
@@ -221,7 +221,7 @@ cd client && flutter test
### 定价字段口径(2026-07 消歧后,唯一口径)
- 明细两侧价格分列:`cost_price`(成本/进价单价)+ `cost_amount`(成本小计);出库另有 `sale_price`(售价)+ `sale_amount`(售价小计,待定价=0)。**旧列 unit_price/total_price/total_amount 已弃用**(值已回填新列,观察一版后 DROP),新代码禁止读写旧列。
- 单据合计:入库 `cost_total`(应付=Σ总进价);出库 `sale_total`(应收=Σ售价小计)+ `profit_total`(总利润=Σ(售价>0?(售价-成本)×数量:0),建单落库,确认售价/确认进价联动重算,重算函数 `recalcStockOutProfit`)。
- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。
- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。**库存同口径**2026-07-06):库存 List 抹空 unit_price、Summary 抹零 stock_value/last_month_value`stripInventoryCost`),前端隐藏成本价/总价列、卡片单价、货值 KPI(`isAdminProvider`)、商品抽屉成本行。
- 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。
### 库存变更
+17
View File
@@ -47,6 +47,17 @@ type inventoryRow struct {
CreatedAt string `json:"created_at"`
}
// stripInventoryCost 服务端兜底:库存成本仅管理员可见(与出库 stripStockOutCost 同口径)。
// operator/readonly 的 List 响应把 unit_price(成本进价)置空,sale_price 不受影响。
func stripInventoryCost(role string, rows []inventoryRow) {
if role == "admin" || role == "superadmin" {
return
}
for i := range rows {
rows[i].UnitPrice = nil
}
}
// List GET /api/v1/inventory
func (h *InventoryHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
@@ -162,6 +173,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
return
}
stripInventoryCost(middleware.GetRole(c), rows)
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
}
@@ -210,6 +222,11 @@ func (h *InventoryHandler) Summary(c *gin.Context) {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
// 库存货值 = Σ(qty×进价),成本口径,仅管理员可见(同 stripInventoryCost
if role := middleware.GetRole(c); role != "admin" && role != "superadmin" {
s.StockValue = 0
s.LastMonthValue = 0
}
c.JSON(http.StatusOK, s)
}
@@ -393,3 +393,52 @@ func TestInventoryHandler_AfterStockInApprove(t *testing.T) {
invItem := data[0].(map[string]interface{})
assert.Equal(t, float64(20), invItem["quantity"])
}
// 库存成本仅管理员可见(stripInventoryCost):operator/readonly 的 List unit_price 抹空、
// Summary 货值抹零;admin 原样返回。与出库 stripStockOutCost 同口径。
func TestInventoryHandler_CostVisibility(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_COST")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
readonly := testutil.CreateTestUser(db, shop.ID, "ro", "pass", "readonly")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
r := setupProtectedRouter(db)
price := 100.0
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID,
Quantity: 10, UnitPrice: &price,
}).Error)
// adminunit_price / stock_value 原样可见
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
w := makeRequest(r, "GET", "/api/v1/inventory", adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
row := parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, float64(100), row["unit_price"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", adminToken, nil)
assert.Equal(t, float64(1000), parseResponse(w)["stock_value"].(float64))
// operator / readonlyunit_price 抹空、货值抹零,其余字段不受影响
for _, u := range []struct {
id uint64
role string
}{{operator.ID, "operator"}, {readonly.ID, "readonly"}} {
token := getAuthToken(u.id, shop.ID, u.role)
w = makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
row = parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Nil(t, row["unit_price"], "role=%s unit_price 应被抹空", u.role)
assert.Equal(t, float64(10), row["quantity"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(0), resp["stock_value"].(float64), "role=%s 货值应抹零", u.role)
assert.Equal(t, float64(0), resp["last_month_value"].(float64))
assert.Equal(t, float64(1), resp["sku_count"].(float64))
assert.Equal(t, float64(10), resp["in_stock_qty"].(float64))
}
}
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/auth/auth_state.dart';
import '../../core/storage/column_prefs.dart';
import '../../core/utils/export_util.dart';
import '../../models/inventory.dart';
@@ -114,12 +115,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
);
}
// 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。
bool get _isAdmin => ref.watch(isAdminProvider);
bool _colAllowed(ColDef c) =>
_isAdmin || (c.key != 'cost' && c.key != 'price');
// ── 列设置(原型 colBtn → openColMenu:多选保持打开) ──
void _openColMenu(BuildContext anchorContext) {
showDsMultiMenu<String>(
anchorContext,
itemsBuilder: () => [
for (final c in _colDefs.where((c) => !c.required))
for (final c in _colDefs.where((c) => !c.required && _colAllowed(c)))
DsMenuItem(
value: c.key,
label: c.label,
@@ -528,6 +534,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final sub = [
if (item.productCode.isNotEmpty) item.productCode,
if (item.spec.isNotEmpty) item.spec,
// 单价(成本进价)仅管理员可见(2026-07-06 用户拍板)
if (_isAdmin && item.unitPrice != null) _fmtCost(item.unitPrice!),
].join(' · ');
return MCard(
onTap: item.productId != null ? () => _openEditor(item) : null,
@@ -626,8 +634,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
.toList();
final hidden = _hiddenCols ?? <String>{};
final visibleCols =
_colDefs.where((c) => !hidden.contains(c.key)).toList();
final visibleCols = _colDefs
.where((c) => !hidden.contains(c.key) && _colAllowed(c))
.toList();
final mobile = context.isMobile;
// 原型 .main{padding:22px 26px}:桌面整页留白统一在此;窄屏保持紧凑。
@@ -715,7 +724,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
MKpiItem(
label: '库存货值',
value: summary != null
// 货值=成本口径,非管理员后端抹零,前端显示占位
value: _isAdmin && summary != null
? yuanWan(summary.stockValue)
: '',
delta: _mDeltaText(valDelta, valTone),
@@ -752,8 +762,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
DsKpi(
title: '库存货值',
value:
summary != null ? yuanWan(summary.stockValue) : '',
value: _isAdmin && summary != null
? yuanWan(summary.stockValue)
: '',
icon: LucideIcons.database,
tone: DsKpiTone.ok,
delta: valDelta,
@@ -27,7 +27,10 @@ import '../../widgets/ds/ds_toast.dart';
/// 明细为可键盘操作的内联网格(名称/系列/规格可搜索+新增,生产日期/批次/数量/单价/售价内联编辑)。
class StockInFormScreen extends ConsumerStatefulWidget {
final int? editOrderId;
const StockInFormScreen({super.key, this.editOrderId});
/// 仅供 golden 测试钉死单据日期(默认 DateTime.now() 会让 golden 随日期漂移)。
final DateTime? initialDate;
const StockInFormScreen({super.key, this.editOrderId, this.initialDate});
@override
ConsumerState<StockInFormScreen> createState() => _StockInFormScreenState();
@@ -39,7 +42,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final _warehouseFocus = FocusNode();
int? _warehouseId;
int? _partnerId;
DateTime _orderDate = DateTime.now();
late DateTime _orderDate = widget.initialDate ?? DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
StockInOrder? _loadedOrder;
@@ -142,7 +142,10 @@ class _ItemRow {
/// 明细行经「从库存批量选择」添加,商品/系列/规格/可用只读,仅数量、售价可内联编辑。
class StockOutFormScreen extends ConsumerStatefulWidget {
final int? editOrderId;
const StockOutFormScreen({super.key, this.editOrderId});
/// 仅供 golden 测试钉死单据日期(默认 DateTime.now() 会让 golden 随日期漂移)。
final DateTime? initialDate;
const StockOutFormScreen({super.key, this.editOrderId, this.initialDate});
@override
ConsumerState<StockOutFormScreen> createState() => _StockOutFormScreenState();
@@ -154,7 +157,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final _warehouseFocus = FocusNode();
int? _warehouseId;
int? _partnerId;
DateTime _orderDate = DateTime.now();
late DateTime _orderDate = widget.initialDate ?? DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
Map<int, double> _inventoryMap = {};
@@ -11,6 +11,7 @@ import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/auth/auth_state.dart';
import '../core/config/app_config.dart';
import '../core/utils/dialog_util.dart';
import '../core/responsive/responsive.dart';
@@ -356,10 +357,14 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
b('${_fmtQty(widget.qty!)} ${widget.unit ?? p.unit}'.trim())
));
}
// 成本仅管理员可见(与库存列表/出库同口径);p.purchasePrice 兜底也要一并门控
final isAdmin = ref.watch(isAdminProvider);
final cost = widget.cost ?? p.purchasePrice;
if (cost != null && cost > 0) rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null && cost != null && cost > 0) {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
if (isAdmin && cost != null && cost > 0) {
rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null) {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
}
}
if ((widget.status ?? '').isNotEmpty) {
rows.add((
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 145 KiB

@@ -242,6 +242,8 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false),
// 成本列/货值/单价仅管理员可见——golden 基准取管理员形态
isAdminProvider.overrideWithValue(true),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
@@ -165,6 +165,8 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false),
// 成本列/货值/单价仅管理员可见——golden 基准取管理员形态(卡片含单价)
isAdminProvider.overrideWithValue(true),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
@@ -73,7 +73,7 @@ void main() {
goldenAcrossThemes(
'stock-in form 录单(桌面)',
goldenPrefix: 'stock_in_form',
child: () => const Scaffold(body: StockInFormScreen()),
child: () => Scaffold(body: StockInFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(1280, 920),
);
@@ -81,7 +81,7 @@ void main() {
goldenAcrossThemes(
'stock-in form 录单(移动)',
goldenPrefix: 'stock_in_form_mobile',
child: () => const Scaffold(body: StockInFormScreen()),
child: () => Scaffold(body: StockInFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(390, 1400),
);
@@ -53,7 +53,7 @@ void main() {
goldenAcrossThemes(
'stock-out form 录单(桌面)',
goldenPrefix: 'stock_out_form',
child: () => const Scaffold(body: StockOutFormScreen()),
child: () => Scaffold(body: StockOutFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(1280, 920),
);
@@ -61,7 +61,7 @@ void main() {
goldenAcrossThemes(
'stock-out form 录单(移动)',
goldenPrefix: 'stock_out_form_mobile',
child: () => const Scaffold(body: StockOutFormScreen()),
child: () => Scaffold(body: StockOutFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(390, 1400),
);