-
库存 · 按 min_stock 派生在售预警缺货
+
库存三态 · status 落库在售库存已售(在售=公开酒单展示;已售=卖光留痕;预警/缺货退出状态列,由数量染色承担)
+
库存 · 数量派生提示预警缺货
单据 · 入库/出库草稿待审核已通过已驳回
往来单位 · 启用停用启用停用供应商客户两者
用户角色管理员普通只读
diff --git a/.superpowers/prototype/screens/icons.js b/.superpowers/prototype/screens/icons.js
index 2a3a4e3..0b7b93a 100644
--- a/.superpowers/prototype/screens/icons.js
+++ b/.superpowers/prototype/screens/icons.js
@@ -9,7 +9,7 @@
// 单一真相源映射:任何屏渲染出的 .badge.b-*(无 .bi/.ico 者)自动注入图标并隐藏圆点。
// 屏内无需手写 svg;新增状态在此登记即可全局生效。
var BADGE_ICON = {
- '在售':'i-cart','预警':'i-alert','缺货':'i-close',
+ '在售':'i-cart','预警':'i-alert','缺货':'i-close','库存':'i-box','已售':'i-check',
'草稿':'i-ic06','待审核':'i-ic04','已审核':'i-check','已拒绝':'i-close','已通过':'i-check','已驳回':'i-close',
'部分退单':'i-undo','已退单':'i-undo','待定价':'i-yen',
'启用':'i-check','停用':'i-close','供应商':'i-box','客户':'i-ic37','两者':'i-refresh',
diff --git a/.superpowers/prototype/screens/inventory.html b/.superpowers/prototype/screens/inventory.html
index c845517..5984f84 100644
--- a/.superpowers/prototype/screens/inventory.html
+++ b/.superpowers/prototype/screens/inventory.html
@@ -54,7 +54,7 @@
-
+
@@ -101,18 +101,18 @@
const ITEMS=[
{name:'茅台 飞天 53°',code:'MT-FT-500',spec:'500ml×6',series:'飞天',batch:'PZ240711',cat:'酱香',qty:128,unit:'件',cost:'¥2,680',price:'¥343,040',prodDate:'2024-07-11',inDate:'2026-06-14',supplier:'贵州茅台经销',value:'¥34.3万',status:'在售'},
{name:'五粮液 普五 52°',code:'WLY-PW-500',spec:'500ml×6',series:'普五',batch:'PZ240428',cat:'浓香',qty:64,unit:'件',cost:'¥1,050',price:'¥67,200',prodDate:'2024-04-28',inDate:'2026-06-14',supplier:'宜宾五粮液',value:'¥6.7万',status:'在售'},
- {name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',cat:'浓香',qty:8,unit:'件',cost:'¥438',price:'¥3,504',prodDate:'2023-11-15',inDate:'2026-06-12',supplier:'绵竹剑南春',value:'¥3,500',status:'缺货'},
+ {name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',cat:'浓香',qty:8,unit:'件',cost:'¥438',price:'¥3,504',prodDate:'2023-11-15',inDate:'2026-06-12',supplier:'绵竹剑南春',value:'¥3,500',status:'已售'},
{name:'国窖 1573',code:'GJ-1573-500',spec:'500ml×6',series:'1573',batch:'PZ240208',cat:'浓香',qty:42,unit:'件',cost:'¥980',price:'¥41,160',prodDate:'2024-02-08',inDate:'2026-06-10',supplier:'泸州老窖',value:'¥4.1万',status:'在售'},
- {name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'500ml×6',series:'梦之蓝M6',batch:'PZ240520',cat:'绵柔',qty:23,unit:'件',cost:'¥620',price:'¥14,260',prodDate:'2024-05-20',inDate:'2026-06-09',supplier:'洋河股份',value:'¥1.4万',status:'预警'},
+ {name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'500ml×6',series:'梦之蓝M6',batch:'PZ240520',cat:'绵柔',qty:23,unit:'件',cost:'¥620',price:'¥14,260',prodDate:'2024-05-20',inDate:'2026-06-09',supplier:'洋河股份',value:'¥1.4万',status:'库存'},
{name:'泸州老窖 特曲',code:'LZLJ-TQ-500',spec:'500ml×6',series:'特曲',batch:'PZ240630',cat:'浓香',qty:156,unit:'件',cost:'¥320',price:'¥49,920',prodDate:'2024-06-30',inDate:'2026-06-08',supplier:'泸州老窖',value:'¥5万',status:'在售'},
{name:'习酒 窖藏 1988',code:'XJ-JC1988',spec:'500ml×6',series:'窖藏1988',batch:'PZ230918',cat:'酱香',qty:31,unit:'件',cost:'¥760',price:'¥23,560',prodDate:'2023-09-18',inDate:'2026-06-05',supplier:'贵州习酒',value:'¥2.4万',status:'在售'},
- {name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',cat:'清香',qty:0,unit:'件',cost:'¥420',price:'¥0',prodDate:'2024-01-12',inDate:'2026-06-03',supplier:'山西杏花村',value:'¥0',status:'缺货'},
+ {name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',cat:'清香',qty:0,unit:'件',cost:'¥420',price:'¥0',prodDate:'2024-01-12',inDate:'2026-06-03',supplier:'山西杏花村',value:'¥0',status:'已售'},
{name:'郎酒 红花郎 15',code:'LJ-HHL15',spec:'500ml×6',series:'红花郎15',batch:'PZ240325',cat:'酱香',qty:54,unit:'件',cost:'¥560',price:'¥30,240',prodDate:'2024-03-25',inDate:'2026-06-01',supplier:'四川郎酒',value:'¥3万',status:'在售'},
- {name:'古井贡 年份原浆',code:'GJG-NF-500',spec:'500ml×6',series:'年份原浆',batch:'PZ240802',cat:'浓香',qty:12,unit:'件',cost:'¥390',price:'¥4,680',prodDate:'2024-08-02',inDate:'2026-05-28',supplier:'古井贡酒',value:'¥4,700',status:'预警'},
+ {name:'古井贡 年份原浆',code:'GJG-NF-500',spec:'500ml×6',series:'年份原浆',batch:'PZ240802',cat:'浓香',qty:12,unit:'件',cost:'¥390',price:'¥4,680',prodDate:'2024-08-02',inDate:'2026-05-28',supplier:'古井贡酒',value:'¥4,700',status:'库存'},
{name:'西凤酒 旗舰版',code:'XF-QJ-500',spec:'500ml×6',series:'旗舰版',batch:'PZ240614',cat:'凤香',qty:88,unit:'件',cost:'¥280',price:'¥24,640',prodDate:'2024-06-14',inDate:'2026-05-25',supplier:'陕西西凤',value:'¥2.5万',status:'在售'},
- {name:'水井坊 井台',code:'SJF-JT-500',spec:'500ml×6',series:'井台',batch:'PZ231209',cat:'浓香',qty:6,unit:'件',cost:'¥680',price:'¥4,080',prodDate:'2023-12-09',inDate:'2026-05-20',supplier:'四川水井坊',value:'¥4,100',status:'缺货'},
+ {name:'水井坊 井台',code:'SJF-JT-500',spec:'500ml×6',series:'井台',batch:'PZ231209',cat:'浓香',qty:6,unit:'件',cost:'¥680',price:'¥4,080',prodDate:'2023-12-09',inDate:'2026-05-20',supplier:'四川水井坊',value:'¥4,100',status:'已售'},
];
-const STATUS=['全部','在售','预警','缺货'];
+const STATUS=['全部','在售','库存','已售'];
const COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'batch',label:'批次号'},{key:'qty',label:'库存',num:true},{key:'cost',label:'成本价',num:true},{key:'price',label:'总价',num:true},{key:'prodDate',label:'生产日期'},{key:'inDate',label:'入库时间'},{key:'supplier',label:'供应商'},{key:'status',label:'状态',filter:'status'},{key:'act',label:'操作',fixed:true}];
const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set()};
diff --git a/.superpowers/prototype/screens/m-inventory.html b/.superpowers/prototype/screens/m-inventory.html
index 4a30f41..bc9908e 100644
--- a/.superpowers/prototype/screens/m-inventory.html
+++ b/.superpowers/prototype/screens/m-inventory.html
@@ -20,7 +20,7 @@
-
+
@@ -38,12 +38,12 @@
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:'剑南春 水晶剑',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:'洋河 梦之蓝 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:'汾酒 青花 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:'在售'},
];
@@ -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, `
编码${it.code}
规格${it.spec}
系列${it.series}
批次号${it.batch}
当前库存${it.qty} ${it.unit}
成本价${it.cost}
供应商${it.supplier}
状态${it.status}
+ openSheet(it.name, `
编码${it.code}
规格${it.spec}
系列${it.series}
批次号${it.batch}
当前库存${it.qty} ${it.unit}
成本价${it.cost}
供应商${it.supplier}
状态${it.status}${it.status==='已售'?'':``}
商品图片
建议主图为白底产品图,可再加细节图(瓶身/防伪)与场景图。
商品介绍 / 卖点
diff --git a/.superpowers/prototype/screens/product-editor.js b/.superpowers/prototype/screens/product-editor.js
index 56dcfbd..cbf2ac8 100644
--- a/.superpowers/prototype/screens/product-editor.js
+++ b/.superpowers/prototype/screens/product-editor.js
@@ -64,7 +64,8 @@
if (it.qty != null) r.push(['当前库存', `
${it.qty} ${it.unit || ''}`]);
if (it.cost) r.push(['成本价', `
${it.cost}`]);
if (it.value) r.push(['库存货值', `
${it.value}`]);
- if (it.status) r.push(['状态', `
${it.status}`]);
+ // 状态行:三态徽章 + 挂售 switch(在售⇄库存 即时生效;已售只读无 switch)
+ if (it.status) r.push(['状态', `
${it.status}${it.status === '已售' ? '' : ``}`]);
return r.map((x, i) => `
${x[0]}${x[1]}
`).join('');
}
diff --git a/client/lib/models/inventory.dart b/client/lib/models/inventory.dart
index a0d2918..a367de9 100644
--- a/client/lib/models/inventory.dart
+++ b/client/lib/models/inventory.dart
@@ -9,6 +9,9 @@ class Inventory {
final int? productId;
final int? stockInItemId;
final double quantity;
+
+ /// 库存三态(2026-07-07):stock=库存 / on_sale=在售(公开酒单) / sold=已售(卖光留痕)
+ final String status;
final String productCode;
final String productName;
final String series;
@@ -31,6 +34,7 @@ class Inventory {
this.productId,
this.stockInItemId,
required this.quantity,
+ this.status = 'stock',
this.productCode = '',
this.productName = '',
this.series = '',
@@ -54,6 +58,7 @@ class Inventory {
productId: (json['product_id'] as num?)?.toInt(),
stockInItemId: (json['stock_in_item_id'] as num?)?.toInt(),
quantity: (json['quantity'] as num).toDouble(),
+ status: json['status'] as String? ?? 'stock',
productCode: json['product_code'] as String? ?? '',
productName: json['product_name'] as String? ?? '',
series: json['series'] as String? ?? '',
@@ -77,6 +82,7 @@ class InventorySummary {
final int skuCount;
final double stockValue;
final double inStockQty;
+ final int soldCount; // 已售留痕行数(status=sold)
final int shortageCount;
final int warningCount;
// 月初快照(KPI 环比):由 inventory_logs 反推
@@ -87,6 +93,7 @@ class InventorySummary {
this.skuCount = 0,
this.stockValue = 0,
this.inStockQty = 0,
+ this.soldCount = 0,
this.shortageCount = 0,
this.warningCount = 0,
this.lastMonthSku = 0,
@@ -97,6 +104,7 @@ class InventorySummary {
skuCount: (j['sku_count'] as num?)?.toInt() ?? 0,
stockValue: (j['stock_value'] as num?)?.toDouble() ?? 0,
inStockQty: (j['in_stock_qty'] as num?)?.toDouble() ?? 0,
+ soldCount: (j['sold_count'] as num?)?.toInt() ?? 0,
shortageCount: (j['shortage_count'] as num?)?.toInt() ?? 0,
warningCount: (j['warning_count'] as num?)?.toInt() ?? 0,
lastMonthSku: (j['last_month_sku'] as num?)?.toInt() ?? 0,
diff --git a/client/lib/providers/inventory_provider.dart b/client/lib/providers/inventory_provider.dart
index 0ee7299..2c393c6 100644
--- a/client/lib/providers/inventory_provider.dart
+++ b/client/lib/providers/inventory_provider.dart
@@ -30,6 +30,7 @@ class InventoryListNotifier extends AsyncNotifier
> {
int? _warehouseId;
String _keyword = '';
String _code = ''; // 商品编码独立筛选(原型 codeInput)
+ String _status = ''; // 三态筛选(空=后端默认排除已售)
List _series = [];
List _spec = [];
PageResult? _cache;
@@ -53,6 +54,7 @@ class InventoryListNotifier extends AsyncNotifier> {
warehouseId: _warehouseId,
keyword: _keyword.isEmpty ? null : _keyword,
code: _code.isEmpty ? null : _code,
+ status: _status.isEmpty ? null : _status,
series: _series.isEmpty ? null : _series,
spec: _spec.isEmpty ? null : _spec,
page: _page,
@@ -89,6 +91,12 @@ class InventoryListNotifier extends AsyncNotifier> {
reload();
}
+ void setStatus(String status) {
+ _status = status;
+ _page = 1;
+ reload();
+ }
+
void setSeries(List series) {
_series = series;
_page = 1;
diff --git a/client/lib/repositories/inventory_repository.dart b/client/lib/repositories/inventory_repository.dart
index 21adf90..825b241 100644
--- a/client/lib/repositories/inventory_repository.dart
+++ b/client/lib/repositories/inventory_repository.dart
@@ -16,6 +16,7 @@ class InventoryRepository {
String? code,
List? series,
List? spec,
+ String? status, // 三态筛选:stock/on_sale/sold(逗号可多值);空=后端默认(排除已售)
int page = 1,
int pageSize = 50,
}) async {
@@ -29,6 +30,7 @@ class InventoryRepository {
if (code != null && code.isNotEmpty) 'code': code,
if (series != null && series.isNotEmpty) 'series': series.join(','),
if (spec != null && spec.isNotEmpty) 'spec': spec.join(','),
+ if (status != null && status.isNotEmpty) 'status': status,
};
final resp = await _client.get('/inventory', params: params);
return PageResult.fromJson(
@@ -67,6 +69,18 @@ class InventoryRepository {
}
}
+ /// 库存⇄在售 挂售切换(status 仅收 stock/on_sale;sold 由出库流程专属)
+ Future updateStatus(int id, String status) async {
+ try {
+ await _client.put('/inventory/$id/status', data: {'status': status});
+ } on DioException catch (e) {
+ throw AppException(
+ e.response?.data?['error'] as String? ?? '状态更新失败',
+ statusCode: e.response?.statusCode,
+ );
+ }
+ }
+
Future updateRemark(int id, String remark) async {
try {
await _client.put('/inventory/$id/remark', data: {'remark': remark});
diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart
index a6d8990..09763ad 100644
--- a/client/lib/screens/inventory/inventory_list_screen.dart
+++ b/client/lib/screens/inventory/inventory_list_screen.dart
@@ -38,12 +38,19 @@ class _InventoryListScreenState extends ConsumerState {
Set _filterWarehouse = {};
Set _filterSpec = {};
Set _filterSeries = {};
- // 库存状态前端筛选(派生自 qty vs 安全库存):全部/在售/预警/缺货
+ // 库存三态筛选(2026-07-07 status 落库,服务端过滤):全部/在售/库存/已售;
+ // 默认「全部」= 后端排除已售(已售仅点已售卡/筛选时显示)。预警缺货退出状态维度。
String _statusFilter = '全部';
+ static const _statusParam = {
+ '全部': '',
+ '在售': 'on_sale',
+ '库存': 'stock',
+ '已售': 'sold',
+ };
Set? _hiddenCols; // null = 尚未载入本地存档
static const _screenId = 'inventory_list';
- static const _statusOptions = ['全部', '在售', '预警', '缺货'];
+ static const _statusOptions = ['全部', '在售', '库存', '已售'];
// 列序照原型 inventory.html COLS:商品/规格/系列/批次/库存/成本价/单价/生产/入库/供应商/状态/操作。
// 成本价=入库进价(unitPrice),总价=库存×成本价(2026-07-03 用户拍板:原「单价/参考售价」列换掉)。
@@ -95,13 +102,21 @@ class _InventoryListScreenState extends ConsumerState {
n.setKeyword('');
n.setSpec([]);
n.setSeries([]);
+ n.setStatus('');
}
- String _statusOf(Inventory i) => i.quantity == 0
- ? '缺货'
- : (i.minStock != null && i.quantity < i.minStock!)
- ? '预警'
- : '在售';
+ // 三态落库后的显示标签(status 列真相源)
+ String _statusOf(Inventory i) => switch (i.status) {
+ 'on_sale' => '在售',
+ 'sold' => '已售',
+ _ => '库存',
+ };
+
+ // 低库存提示(预警/缺货退出状态列后由数量染色承担)
+ bool _isLowStock(Inventory i) =>
+ i.status != 'sold' &&
+ (i.quantity == 0 ||
+ (i.minStock != null && i.quantity < i.minStock!));
void _openEditor(Inventory item) {
if (item.productId == null) return;
@@ -112,9 +127,20 @@ class _InventoryListScreenState extends ConsumerState {
unit: item.unit,
cost: item.unitPrice,
status: _statusOf(item),
+ inventoryId: item.id,
+ inventoryStatus: item.status,
+ onStatusChanged: () =>
+ ref.read(inventoryListProvider.notifier).reload(),
);
}
+ void _applyStatusFilter(String label) {
+ setState(() => _statusFilter = label);
+ ref
+ .read(inventoryListProvider.notifier)
+ .setStatus(_statusParam[label] ?? '');
+ }
+
// 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。
bool get _isAdmin => ref.watch(isAdminProvider);
bool _colAllowed(ColDef c) =>
@@ -149,7 +175,7 @@ class _InventoryListScreenState extends ConsumerState {
DsMenuItem(value: s, label: s, selected: s == _statusFilter),
],
);
- if (v != null && mounted) setState(() => _statusFilter = v);
+ if (v != null && mounted) _applyStatusFilter(v);
}
// ── 多选漏斗(规格/系列/仓库) ──
@@ -223,7 +249,7 @@ class _InventoryListScreenState extends ConsumerState {
selected: _statusFilter == s,
caret: false,
onTap: () {
- setState(() => _statusFilter = s);
+ _applyStatusFilter(s);
setSheet(() {});
}),
]),
@@ -512,12 +538,12 @@ class _InventoryListScreenState extends ConsumerState {
return '¥$buf${parts.length > 1 ? '.${parts[1]}' : ''}';
}
- /// 状态徽章(DsBadge):派生 qty vs 安全库存 → 在售/预警/缺货。
+ /// 状态徽章(DsBadge):三态落库 status 真相源。
Widget _dsStatusBadge(Inventory item) {
- return switch (_statusOf(item)) {
- '缺货' => const DsBadge('缺货', tone: DsBadgeTone.danger),
- '预警' => const DsBadge('预警', tone: DsBadgeTone.warn),
- _ => const DsBadge('在售', tone: DsBadgeTone.ok),
+ return switch (item.status) {
+ 'on_sale' => const DsBadge('在售', tone: DsBadgeTone.ok),
+ 'sold' => const DsBadge('已售', tone: DsBadgeTone.muted),
+ _ => const DsBadge('库存', tone: DsBadgeTone.info),
};
}
@@ -526,11 +552,12 @@ class _InventoryListScreenState extends ConsumerState {
Widget _inventoryCard(Inventory item) {
final t = context.tokens;
final status = _statusOf(item);
- final qtyColor = switch (status) {
- '缺货' => t.danger,
- '预警' => t.warn,
- _ => t.heading,
- };
+ // 低库存数量染色(预警/缺货退出状态维度后由数量承担)
+ final qtyColor = !_isLowStock(item)
+ ? t.heading
+ : item.quantity == 0
+ ? t.danger
+ : t.warn;
final sub = [
if (item.productCode.isNotEmpty) item.productCode,
if (item.spec.isNotEmpty) item.spec,
@@ -626,11 +653,11 @@ class _InventoryListScreenState extends ConsumerState {
?.map((s) => s.name)
.toList() ??
[];
+ // 状态筛选已走服务端(setStatus);仓库仍为当前页客户端筛选
final filteredItems = items
.where((i) =>
- (_filterWarehouse.isEmpty ||
- _filterWarehouse.contains(i.warehouseName)) &&
- (_statusFilter == '全部' || _statusOf(i) == _statusFilter))
+ _filterWarehouse.isEmpty ||
+ _filterWarehouse.contains(i.warehouseName))
.toList();
final hidden = _hiddenCols ?? {};
@@ -739,12 +766,12 @@ class _InventoryListScreenState extends ConsumerState {
deltaTone: _mTone(qtyTone),
),
MKpiItem(
- label: '缺货预警',
- value: '${summary?.shortageCount ?? 0}',
+ label: '已售',
+ value: '${summary?.soldCount ?? 0}',
delta: '需补货 ${summary?.warningCount ?? 0} 项',
deltaTone: MKpiDeltaTone.warn,
- selected: _statusFilter == '缺货',
- onTap: () => setState(() => _statusFilter = '缺货'),
+ selected: _statusFilter == '已售',
+ onTap: () => _applyStatusFilter('已售'),
),
]),
);
@@ -780,15 +807,15 @@ class _InventoryListScreenState extends ConsumerState {
deltaTone: qtyTone,
),
DsKpi(
- title: '缺货预警 · 点击筛选',
- value: '${summary?.shortageCount ?? 0}',
- icon: LucideIcons.triangleAlert,
+ title: '已售 · 点击筛选',
+ value: '${summary?.soldCount ?? 0}',
+ icon: LucideIcons.check,
tone: DsKpiTone.alert,
delta: '需补货 ${summary?.warningCount ?? 0} 项',
deltaTone: (summary?.warningCount ?? 0) > 0
? DsKpiDelta.down
: DsKpiDelta.neutral,
- onTap: () => setState(() => _statusFilter = '缺货'),
+ onTap: () => _applyStatusFilter('已售'),
),
];
final row = [];
diff --git a/client/lib/widgets/ds/ds_switch.dart b/client/lib/widgets/ds/ds_switch.dart
new file mode 100644
index 0000000..5def4e3
--- /dev/null
+++ b/client/lib/widgets/ds/ds_switch.dart
@@ -0,0 +1,55 @@
+// widgets/ds/ds_switch.dart — 原型 atoms.css .switch 的 Flutter 对照(2026-07-07 登记)。
+// 38×22 pill 轨道 + 18px 圆点滑块:on=primary / off=border / disabled 45% 透明。
+// 用于布尔即时生效场景(库存⇄在售挂售切换);不同于 Material Switch 的尺寸与配色。
+import 'package:flutter/material.dart';
+
+import '../../core/theme/context_tokens.dart';
+
+class DsSwitch extends StatelessWidget {
+ final bool value;
+ final ValueChanged? onChanged; // null = disabled
+ const DsSwitch({super.key, required this.value, this.onChanged});
+
+ @override
+ Widget build(BuildContext context) {
+ final t = context.tokens;
+ final enabled = onChanged != null;
+ return Semantics(
+ toggled: value,
+ button: true,
+ child: GestureDetector(
+ onTap: enabled ? () => onChanged!(!value) : null,
+ child: Opacity(
+ opacity: enabled ? 1 : .45,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 180),
+ width: 38,
+ height: 22,
+ padding: const EdgeInsets.all(2),
+ decoration: BoxDecoration(
+ color: value ? t.primary : t.border,
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: AnimatedAlign(
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeOut,
+ alignment: value ? Alignment.centerRight : Alignment.centerLeft,
+ child: Container(
+ width: 18,
+ height: 18,
+ decoration: BoxDecoration(
+ color: t.surface,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: t.shadow, blurRadius: 3, offset: const Offset(0, 1)),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/client/lib/widgets/ds/status_icon_map.dart b/client/lib/widgets/ds/status_icon_map.dart
index 0493c79..7c316ac 100644
--- a/client/lib/widgets/ds/status_icon_map.dart
+++ b/client/lib/widgets/ds/status_icon_map.dart
@@ -5,8 +5,10 @@ import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
const Map kStatusIcons = {
- // 库存状态
+ // 库存状态(三态落库 2026-07-07:在售/库存/已售;预警/缺货为数量派生提示)
'在售': LucideIcons.shoppingCart, // i-cart
+ '库存': LucideIcons.box, // i-box
+ '已售': LucideIcons.check, // i-check
'预警': LucideIcons.triangleAlert, // i-alert
'缺货': LucideIcons.x, // i-close
// 单据状态
diff --git a/client/lib/widgets/product_editor_drawer.dart b/client/lib/widgets/product_editor_drawer.dart
index 57010b0..a8fc1eb 100644
--- a/client/lib/widgets/product_editor_drawer.dart
+++ b/client/lib/widgets/product_editor_drawer.dart
@@ -20,10 +20,12 @@ import '../core/theme/app_dims.g.dart';
import '../core/theme/context_tokens.dart';
import '../models/product.dart';
import '../models/product_image.dart';
+import '../providers/inventory_provider.dart' show inventoryRepositoryProvider;
import '../providers/product_option_provider.dart';
import '../providers/product_provider.dart';
import '../providers/shop_provider.dart' show shopInfoProvider;
import 'ds/ds_atoms.dart';
+import 'ds/ds_switch.dart';
import 'ds/grid_combo_cell.dart';
import 'ds/m_sheet.dart';
import 'searchable_option_field.dart';
@@ -41,7 +43,10 @@ Future showProductEditorDrawer(
double? qty,
String? unit,
double? cost,
- String? status, // 在售 / 预警 / 缺货
+ String? status, // 在售 / 库存 / 已售(三态显示标签)
+ int? inventoryId, // 库存行 id:非空时状态行显示挂售 switch
+ String? inventoryStatus, // stock / on_sale / sold(switch 初始态)
+ VoidCallback? onStatusChanged, // switch 切换成功后回调(列表刷新)
}) {
// 窄屏:底部 sheet 形态(原型 m-inventory openItem 内嵌公开页编辑)
if (context.isMobile) {
@@ -56,7 +61,10 @@ Future showProductEditorDrawer(
qty: qty,
unit: unit,
cost: cost,
- status: status),
+ status: status,
+ inventoryId: inventoryId,
+ inventoryStatus: inventoryStatus,
+ onStatusChanged: onStatusChanged),
),
);
}
@@ -83,7 +91,10 @@ Future showProductEditorDrawer(
qty: qty,
unit: unit,
cost: cost,
- status: status),
+ status: status,
+ inventoryId: inventoryId,
+ inventoryStatus: inventoryStatus,
+ onStatusChanged: onStatusChanged),
),
),
),
@@ -106,12 +117,18 @@ class _ProductEditorDrawer extends ConsumerStatefulWidget {
final String? unit;
final double? cost;
final String? status;
+ final int? inventoryId;
+ final String? inventoryStatus;
+ final VoidCallback? onStatusChanged;
const _ProductEditorDrawer({
required this.productId,
this.qty,
this.unit,
this.cost,
this.status,
+ this.inventoryId,
+ this.inventoryStatus,
+ this.onStatusChanged,
});
@override
@@ -125,6 +142,32 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
bool _uploading = false;
bool _saving = false;
+ // 库存三态挂售 switch(stock⇄on_sale;sold 只读无 switch)
+ late String? _invStatus = widget.inventoryStatus;
+ bool _statusSaving = false;
+
+ Future _toggleOnSale(bool on) async {
+ final id = widget.inventoryId;
+ if (id == null || _statusSaving) return;
+ final next = on ? 'on_sale' : 'stock';
+ final prev = _invStatus;
+ setState(() {
+ _invStatus = next;
+ _statusSaving = true;
+ });
+ try {
+ await ref.read(inventoryRepositoryProvider).updateStatus(id, next);
+ widget.onStatusChanged?.call();
+ } catch (e) {
+ if (mounted) {
+ setState(() => _invStatus = prev);
+ showDsToast(context, '状态更新失败:$e', bg: context.tokens.danger);
+ }
+ } finally {
+ if (mounted) setState(() => _statusSaving = false);
+ }
+ }
+
// 介绍选择态(.pe-pick / .pe-text)
String? _introTitle; // 已选介绍库标题;null → 占位「从介绍库选择…」
int? _introDocId;
@@ -366,15 +409,40 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
}
}
- if ((widget.status ?? '').isNotEmpty) {
+ // 状态行:三态徽章 + 挂售 switch(在售⇄库存 即时生效;已售只读;readonly 禁用)
+ final invStatus = _invStatus;
+ final statusLabel = switch (invStatus) {
+ 'on_sale' => '在售',
+ 'sold' => '已售',
+ 'stock' => '库存',
+ _ => widget.status ?? '',
+ };
+ if (statusLabel.isNotEmpty) {
+ final badge = DsBadge(statusLabel,
+ tone: switch (statusLabel) {
+ '在售' => DsBadgeTone.ok,
+ '已售' => DsBadgeTone.muted,
+ '库存' => DsBadgeTone.info,
+ '缺货' => DsBadgeTone.danger,
+ '预警' => DsBadgeTone.warn,
+ _ => DsBadgeTone.ok,
+ });
+ final canToggle = widget.inventoryId != null &&
+ invStatus != null &&
+ invStatus != 'sold' &&
+ !ref.watch(isReadonlyProvider);
rows.add((
'状态',
- DsBadge(widget.status!,
- tone: switch (widget.status) {
- '缺货' => DsBadgeTone.danger,
- '预警' => DsBadgeTone.warn,
- _ => DsBadgeTone.ok,
- })
+ Row(mainAxisSize: MainAxisSize.min, children: [
+ badge,
+ if (widget.inventoryId != null && invStatus != null && invStatus != 'sold') ...[
+ const SizedBox(width: 10),
+ DsSwitch(
+ value: invStatus == 'on_sale',
+ onChanged: canToggle && !_statusSaving ? _toggleOnSale : null,
+ ),
+ ],
+ ])
));
}
return Column(
diff --git a/client/test/golden/goldens/inventory_list_a.png b/client/test/golden/goldens/inventory_list_a.png
index 8ebc29c..908b160 100644
Binary files a/client/test/golden/goldens/inventory_list_a.png and b/client/test/golden/goldens/inventory_list_a.png differ
diff --git a/client/test/golden/goldens/inventory_list_b.png b/client/test/golden/goldens/inventory_list_b.png
index 2f629f7..b38c05c 100644
Binary files a/client/test/golden/goldens/inventory_list_b.png and b/client/test/golden/goldens/inventory_list_b.png differ
diff --git a/client/test/golden/goldens/inventory_list_c.png b/client/test/golden/goldens/inventory_list_c.png
index bf5e4d0..998ec24 100644
Binary files a/client/test/golden/goldens/inventory_list_c.png and b/client/test/golden/goldens/inventory_list_c.png differ
diff --git a/client/test/golden/goldens/inventory_list_mobile_a.png b/client/test/golden/goldens/inventory_list_mobile_a.png
index 6cdb47c..6b227a1 100644
Binary files a/client/test/golden/goldens/inventory_list_mobile_a.png and b/client/test/golden/goldens/inventory_list_mobile_a.png differ
diff --git a/client/test/golden/goldens/inventory_list_mobile_b.png b/client/test/golden/goldens/inventory_list_mobile_b.png
index 24dd9ef..4e9b5f2 100644
Binary files a/client/test/golden/goldens/inventory_list_mobile_b.png and b/client/test/golden/goldens/inventory_list_mobile_b.png differ
diff --git a/client/test/golden/goldens/inventory_list_mobile_c.png b/client/test/golden/goldens/inventory_list_mobile_c.png
index b323169..de587bc 100644
Binary files a/client/test/golden/goldens/inventory_list_mobile_c.png and b/client/test/golden/goldens/inventory_list_mobile_c.png differ
diff --git a/client/test/golden/goldens/m_inventory_a.png b/client/test/golden/goldens/m_inventory_a.png
index dc2de1a..226af52 100644
Binary files a/client/test/golden/goldens/m_inventory_a.png and b/client/test/golden/goldens/m_inventory_a.png differ
diff --git a/client/test/golden/goldens/m_inventory_b.png b/client/test/golden/goldens/m_inventory_b.png
index d07ea28..cff175d 100644
Binary files a/client/test/golden/goldens/m_inventory_b.png and b/client/test/golden/goldens/m_inventory_b.png differ
diff --git a/client/test/golden/goldens/m_inventory_c.png b/client/test/golden/goldens/m_inventory_c.png
index 91c8d82..ecb4dea 100644
Binary files a/client/test/golden/goldens/m_inventory_c.png and b/client/test/golden/goldens/m_inventory_c.png differ
diff --git a/client/test/golden/inventory_list_golden_test.dart b/client/test/golden/inventory_list_golden_test.dart
index 01c0233..70f1469 100644
--- a/client/test/golden/inventory_list_golden_test.dart
+++ b/client/test/golden/inventory_list_golden_test.dart
@@ -27,6 +27,7 @@ const _items = [
id: 1,
productId: 1,
quantity: 128,
+ status: 'on_sale',
productCode: 'MT-FT-500',
productName: '茅台 飞天 53°',
series: '飞天',
@@ -153,6 +154,7 @@ const _items = [
id: 8,
productId: 8,
quantity: 0,
+ status: 'sold',
productCode: 'FJ-QH20-500',
productName: '汾酒 青花 20',
series: '青花20',
@@ -249,6 +251,7 @@ List _overrides() => [
stockValue: 2640000,
inStockQty: 18920,
shortageCount: 17,
+ soldCount: 17,
warningCount: 6,
// 月初快照 → 环比 ▲2.3% / ▲1.1% / ▼0.6%(对齐原型示例文案)
lastMonthSku: 1255,
diff --git a/client/test/golden/inventory_list_mobile_golden_test.dart b/client/test/golden/inventory_list_mobile_golden_test.dart
index bbd3634..30829e7 100644
--- a/client/test/golden/inventory_list_mobile_golden_test.dart
+++ b/client/test/golden/inventory_list_mobile_golden_test.dart
@@ -22,6 +22,7 @@ const _items = [
id: 1,
productId: 1,
quantity: 128,
+ status: 'on_sale',
productCode: 'MT-FT-500',
productName: '茅台 飞天 53°',
series: '飞天',
@@ -76,6 +77,7 @@ const _items = [
id: 8,
productId: 8,
quantity: 0,
+ status: 'sold',
productCode: 'FJ-QH20-500',
productName: '汾酒 青花 20',
series: '青花20',
@@ -172,6 +174,7 @@ List _overrides() => [
stockValue: 2640000,
inStockQty: 18920,
shortageCount: 17,
+ soldCount: 17,
warningCount: 6,
lastMonthSku: 1255,
lastMonthValue: 2611276,
diff --git a/client/test/golden/product_detail_golden_test.dart b/client/test/golden/product_detail_golden_test.dart
index 8fba836..87356df 100644
--- a/client/test/golden/product_detail_golden_test.dart
+++ b/client/test/golden/product_detail_golden_test.dart
@@ -51,6 +51,7 @@ class _FakeInvRepo implements InventoryRepository {
String? code,
List? series,
List? spec,
+ String? status,
int page = 1,
int pageSize = 50,
}) async =>
diff --git a/client/test/stock_out_insufficient_stock_flow_test.dart b/client/test/stock_out_insufficient_stock_flow_test.dart
index 5f4b96a..46d1c23 100644
--- a/client/test/stock_out_insufficient_stock_flow_test.dart
+++ b/client/test/stock_out_insufficient_stock_flow_test.dart
@@ -99,6 +99,7 @@ class _FakeInventoryRepository extends InventoryRepository {
String? code,
List? series,
List? spec,
+ String? status,
int page = 1,
int pageSize = 50,
}) async {