Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31bf645c30 | |||
| 3573f015c3 | |||
| d9f1f6956e | |||
| 75d0accb14 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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`)、商品抽屉成本行。
|
||||
- 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。
|
||||
|
||||
### 库存变更
|
||||
|
||||
@@ -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)
|
||||
|
||||
// admin:unit_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 / readonly:unit_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((
|
||||
|
||||
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 142 KiB |
|
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),
|
||||
);
|
||||
|
||||