diff --git a/.superpowers/prototype/screens/stock-in.html b/.superpowers/prototype/screens/stock-in.html index 42051f6..f6905c7 100644 --- a/.superpowers/prototype/screens/stock-in.html +++ b/.superpowers/prototype/screens/stock-in.html @@ -170,6 +170,7 @@
明细 0 行 +
合计金额 ¥0.00
diff --git a/.superpowers/prototype/screens/stock-in.js b/.superpowers/prototype/screens/stock-in.js index 726573e..f523443 100644 --- a/.superpowers/prototype/screens/stock-in.js +++ b/.superpowers/prototype/screens/stock-in.js @@ -54,7 +54,7 @@ const MODES = { in:{ title:'入库单', partnerLabel:'供应商', partnerDict:'suppliers', handler:'入库员', cols:['idx','name','series','spec','prodDate','batch','qty','price','sale','amount','act'] }, out:{ title:'出库单', partnerLabel:'客户', partnerDict:'customers', handler:'出库员', - cols:['idx','name','series','spec','avail','qty','price','sale','amount','act'] }, + cols:['idx','name','series','spec','avail','qty','price','sale','profit','act'] }, }; const COLDEF = { idx:{type:'idx',w:38}, @@ -65,9 +65,10 @@ const COLDEF = { batch:{type:'text',label:'批次号',req:true,w:108,ph:'必填'}, avail:{type:'avail',label:'可用库存',w:92,num:true}, qty:{type:'num',label:'数量',req:true,w:78,num:true}, - price:{type:'num',label:'单价',req:true,w:96,num:true,money:true}, - sale:{type:'num',label:'出售价格',req:true,w:104,num:true,money:true}, - amount:{type:'amount',label:'金额',w:104,num:true}, + price:{type:'num',label:'进价',req:true,w:96,num:true,money:true}, + sale:{type:'num',label:'售价',req:true,w:104,num:true,money:true}, + amount:{type:'amount',label:'总进价',w:104,num:true}, + profit:{type:'profit',label:'利润',w:104,num:true}, act:{type:'act',w:62}, }; // 录单可聚焦字段顺序(出库的 series/spec/avail 只读,不进流) @@ -83,8 +84,12 @@ function newRow(){ return {id:'r'+(++SEQ), name:null,series:null,spec:null,prodD /* ---------------- 工具 ---------------- */ function idName(dictKey,id){ if(id==null) return ''; const o=(DICT[dictKey]||[]).find(x=>x.id===id); return o?o.name:''; } function fmt(n){ return '¥'+(Number(n)||0).toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}); } -function rowAmount(r){ return (parseFloat(r.qty)||0)*(parseFloat(r.price)||0); } +function rowAmount(r){ const unit=mode==='out'?(parseFloat(r.sale)||0):(parseFloat(r.price)||0); return (parseFloat(r.qty)||0)*unit; } function grandTotal(){ return state.rows.reduce((s,r)=>s+rowAmount(r),0); } +// 出库行利润 =(售价 − 进价)× 数量;售价未填不计 +function rowProfit(r){ const sale=parseFloat(r.sale)||0; if(sale<=0) return 0; return (sale-(parseFloat(r.price)||0))*(parseFloat(r.qty)||0); } +function grandProfit(){ return state.rows.reduce((s,r)=>s+rowProfit(r),0); } +function profitHTML(r){ const sale=parseFloat(r.sale)||0; if(sale<=0) return ''; const v=rowProfit(r); return `${fmt(v)}`; } // 入库进价留空/0 = 待定价(暂估),金额显示「待定价」橙色;确认进价后回填 function isPending(r){ return mode==='in' && (!r.price || (parseFloat(r.price)||0)<=0); } function amtHTML(r){ return isPending(r) ? '待定价' : fmt(rowAmount(r)); } @@ -198,7 +203,7 @@ function pickCombo(combo,item){ r[field]=id; r._err.delete(field); if(field==='name'){ const nm=NAMES.find(n=>n.id===id); if(mode==='in'){ if(!r.series)r.series=nm.dSeries; if(!r.spec)r.spec=nm.dSpec; if(!r.price)r.price=String(nm.cost); if(!r.sale)r.sale=String(nm.sale); } - else { r.series=nm.dSeries; r.spec=nm.dSpec; r.price=String(nm.sale); r.sale=String(nm.sale); r.avail=nm.stock; } + else { r.series=nm.dSeries; r.spec=nm.dSpec; r.price=String(nm.cost); r.sale=String(nm.sale); r.avail=nm.stock; } } closePop(); renderDetail(); updateTotals(); draftSave(); @@ -268,20 +273,21 @@ function renderDetailHead(){ const el=document.getElementById('dhAct'); if(!el) function toggleExpand(i){ state.rows[i].expanded=!state.rows[i].expanded; renderDetail(); } function gridHTML(){ const cols=MODES[mode].cols; const ro=readonlyMode(); - let head=''+cols.map(k=>{ const d=COLDEF[k]; if(k==='idx')return '#'; if(k==='act')return ''; const req=d.req&&!(mode==='in'&&k==='price'); const cls=(d.num?'num ':'')+(req?'req':''); const style=d.w?`style="width:${d.w}px"`:''; const lbl=(mode==='in'&&k==='price')?'单价(可待定)':(d.label||''); return `${lbl}`; }).join('')+''; + let head=''+cols.map(k=>{ const d=COLDEF[k]; if(k==='idx')return '#'; if(k==='act')return ''; const req=d.req&&!(mode==='in'&&k==='price'); const cls=(d.num?'num ':'')+(req?'req':''); const style=d.w?`style="width:${d.w}px"`:''; const IN_LBL={price:'进价(单瓶)',sale:'参考售价'}; const lbl=(mode==='in'&&k==='price')?'进价(单瓶)(可待定)':((mode==='in'&&IN_LBL[k])||d.label||''); return `${lbl}`; }).join('')+''; let body=state.rows.map((r,i)=>{ const rerr=r._err.size?'error':''; const tds=cols.map(k=>{ const d=COLDEF[k]; if(k==='idx') return `${i+1}`; - if(k==='name') return `${comboCellHTML(i,'name',true)}`; + if(k==='name'){ const nm=(DICT.names||[]).find(x=>x.id===r.name); const code=(mode==='out'&&nm&&nm.code)?`
${esc(nm.code)}
`:''; return `${comboCellHTML(i,'name',true)}${code}`; } if(k==='series'||k==='spec'){ if(mode==='out') return `${esc(idName(d.dict,r[k]))||''}`; return `${comboCellHTML(i,k,true)}`; } if(k==='avail'){ const v=r.avail; const c=v==null?'color:var(--faint)':(v<=0?'color:var(--danger)':''); return `${v==null?'—':v}`; } if(k==='prodDate'){ const ph=r.prodDate?'':' ph'; return `
${r.prodDate?esc(r.prodDate):'选择日期'}
`; } if(k==='batch') return ``; if(k==='qty') return ``; - if(k==='price') return ``; + if(k==='price'){ if(mode==='out') return `${r.price?fmt(parseFloat(r.price)||0):'—'}`; return ``; } if(k==='sale') return ``; if(k==='amount') return `${amtHTML(r)}`; + if(k==='profit') return `${profitHTML(r)}`; if(k==='act'){ const exp = mode==='in'?``:''; return `
${exp}
`; } return ''; }).join(''); @@ -308,9 +314,9 @@ function cardsHTML(){ rows+=mrow('可用库存',false,`${r.avail==null?'—':r.avail}`); } rows+=mrow('数量',true,``); - rows+=mrow(mode==='in'?'单价(可待定)':'单价',mode!=='in',``); - rows+=mrow('出售价格',true,``); - rows+=`
${amtHTML(r)}
`; + rows+=mrow(mode==='in'?'进价·单瓶(可待定)':'进价',false,``); + rows+=mrow(mode==='in'?'参考售价':'售价',true,``); + rows+= mode==='out' ? `
${profitHTML(r)}
` : `
${amtHTML(r)}
`; const act=ro?'':`
`; return `
商品 ${i+1}${sub?`${esc(sub)}`:''}${act}
${rows}
`; }).join(''); @@ -320,8 +326,9 @@ function cardsHTML(){ function mrow(label,req,inner){ return `
${inner}
`; } function updateTotals(){ - state.rows.forEach((r,i)=>{ const el=document.querySelector(`[data-amt="${i}"]`); if(el) el.innerHTML=amtHTML(r); }); + state.rows.forEach((r,i)=>{ const el=document.querySelector(`[data-amt="${i}"]`); if(el) el.innerHTML=amtHTML(r); const pl=document.querySelector(`[data-profit="${i}"]`); if(pl) pl.innerHTML=profitHTML(r); }); document.getElementById('grandTotal').textContent=fmt(grandTotal()); + const pw=document.getElementById('profitWrap'); if(pw){ pw.style.display=mode==='out'?'':'none'; const gp=document.getElementById('grandProfit'); if(gp){ const v=grandProfit(); gp.textContent=fmt(v); gp.style.color=v<0?'var(--danger)':'var(--success)'; } } document.getElementById('rowsN').textContent='明细 '+state.rows.length+' 行'; } @@ -356,7 +363,6 @@ function validate(asDraft){ } else { if(r.avail!=null && (parseFloat(r.qty)||0)>r.avail){ r._err.add('qty'); flag(`第 ${i+1} 行数量超出可用库存(${r.avail})`,()=>focusCell(i,'qty')); } } - if(mode!=='in' && (!r.price || (parseFloat(r.price)||0)<=0)){ r._err.add('price'); flag(`第 ${i+1} 行请填写单价`,()=>focusCell(i,'price')); } if(!r.sale || (parseFloat(r.sale)||0)<=0){ r._err.add('sale'); flag(`第 ${i+1} 行请填写出售价格`,()=>focusCell(i,'sale')); } }); } diff --git a/.superpowers/prototype/screens/stock-out-list.html b/.superpowers/prototype/screens/stock-out-list.html index a2bbcf1..67df7d7 100644 --- a/.superpowers/prototype/screens/stock-out-list.html +++ b/.superpowers/prototype/screens/stock-out-list.html @@ -295,11 +295,12 @@ function auditBtns(o,idx){ return ''; } function openDetail(idx){ const o=ORDERS[idx]; document.getElementById('dTitle').textContent=o.no; - let lh=`
商品 · 编码系列 / 规格数量单价金额
`; - o.lines.forEach(l=>{ const pi=PINFO[l.name]||{}; const pend=(l.price||0)<=0; const priceC=pend?'待定价':money(l.price); const amtC=pend?'待定价':money(l.qty*l.price); lh+=`
${l.name}${pi.code||'—'}${pi.series||'—'}${pi.spec||''}${l.qty}${priceC}${amtC}
`; }); lh+='
'; + const GRID='1fr 108px 36px 60px 60px 64px'; + let lh=`
商品 · 编码系列 / 规格数量成本价售价利润
`; + o.lines.forEach(l=>{ const pi=PINFO[l.name]||{}; const cost=l.cost!=null?l.cost:Math.round((l.price||0)*0.85); const pend=(l.price||0)<=0; const saleC=pend?'待定价':money(l.price); const profit=(l.price-cost)*l.qty; const profitC=pend?'待定价':`${money(profit)}`; lh+=`
${l.name}${pi.code||'—'}${pi.series||'—'}${pi.spec||''}${l.qty}${money(cost)}${saleC}${profitC}
`; }); lh+='
'; const pendCount=o.lines.filter(l=>(l.price||0)<=0).length; const auditor=o.auditor?o.auditor:''; - document.getElementById('drawerBody').innerHTML=`
单据日期${o.date}
${PARTY_LABEL}${o.party}
仓库${o.wh}
商品数${o.count}
出库员${o.op}
审核员${auditor}
状态${o.status}${o.ret?` ${o.ret}`:''}
${lh}
合计金额 ${money(o.amount)}
`+ + document.getElementById('drawerBody').innerHTML=`
单据日期${o.date}
${PARTY_LABEL}${o.party}
仓库${o.wh}
商品数${o.count}
出库员${o.op}
审核员${auditor}
状态${o.status}${o.ret?` ${o.ret}`:''}
${lh}
合计金额 ${money(o.amount)}
合计利润 ${money(o.lines.reduce((s,l)=>{const c=l.cost!=null?l.cost:Math.round((l.price||0)*0.85);return s+((l.price||0)>0?((l.price-c)*l.qty):0);},0))}
`+ `
单据审核
${auditBtns(o,idx)}
${(o.status==='已审核'&&pendCount>0)?`
售价 · ${pendCount} 项待定价
确认售价
`:''}
打印
打印标签
打印单据
`; document.getElementById('drawer').classList.add('show'); document.getElementById('drawerMask').classList.add('show'); } function closeDrawer(){ document.getElementById('drawer').classList.remove('show'); document.getElementById('drawerMask').classList.remove('show'); } diff --git a/CLAUDE.md b/CLAUDE.md index 8bb66e8..6d1e731 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,12 @@ cd client && flutter test - 基础数据字典(品牌/型号/版本 + 产地/保质期/储存/介绍)在「基础数据」页管理,入库选文本后据此建 product。 - 库存(`inventories`)/出入库明细 = 指向 product 的引用 + **快照列**(`product_code/name/series/spec/...`,导入/审核时拷贝,作历史保真+显示兜底)。显示优先 product、回退快照(`COALESCE(p.*, 快照)` / 前端 `lineOrProduct`)。**快照列保留,勿擅自删**。 +### 定价字段口径(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`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。 +- 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。 + ### 库存变更 - 入库/出库审核通过时,必须在同一事务中同时完成: 1. 更新 `inventories` 表数量 diff --git a/design/CONTRACT.md b/design/CONTRACT.md index b12d0f5..d7423ae 100644 --- a/design/CONTRACT.md +++ b/design/CONTRACT.md @@ -150,6 +150,13 @@ AppTokens 目前**仅颜色**。原型还驱动: > - **注册已知差异(用户拍板 2026-07-03:先不加)**:无「门店编号(留空自动分配)」「授权兑换券」两字段——后端 RegisterInput 暂不支持;门店地址必填(后端 binding:required,原型选填);密码下限 6 位(后端 min=6,原型文案 8 位)。表单因此少两行 → **fidelity 暂撤册**(screens.mjs 有注释存根),golden ×3 锁回归;后端补 shop_code/voucher 后恢复入闸。 > - 校验口径 = 原型:提交时逐项 toast(DsToast),无内联错误文案,38 高盒式字段不抖动。 > +> **出入库定价重设计(2026-07-03 用户拍板,plan 批准)**: +> - 出库详情明细:单价/金额 两列 → **成本价/售价/利润** 三列(管理员);operator 见 售价/小计。合计区双行:合计金额(总售价)+ 合计利润(管理员)。 +> - 出库建单:金额列(=售价×数量,qty=1 与售价重复)→ **利润**列(实时,负数 danger);商品名称下带编码(字体同系列列);底栏加合计利润;进价/利润列仅管理员。 +> - 入库建单列头:进价 → **进价(单瓶)**、售价 → **参考售价**、金额 → **总进价**。 +> - 原型已同步(stock-in.js 双模式 + stock-out-list.html 详情抽屉),与真实页一致。 +> - 底层字段消歧:unit_price/total_price/total_amount → cost_price/cost_amount/cost_total(入)/sale_amount/sale_total/profit_total(出),见 CLAUDE.md「定价字段口径」。 +> > **全局对话框统一(2026-07-03 用户要求遍历)**: > - 主题层钉真相源:`DialogTheme`(surface/r-xl/边框/标题 fs-h2·700)、全局 `InputDecorationTheme`(.input 盒式,禁下划线兜底)、Elevated/FilledButton 主题(.btn.primary 规格)。 > - 全部 AlertDialog 动作行换 `DsButton`(取消=ghost / 确认=primary / 删除·拒绝=danger / 通过=success / 撤回·结清=accent,`DsBtnVariant` 扩 success/accent);表单字段改 `DsField`(label 在上)+ 盒式输入(添加设备等)。 diff --git a/docs/db-schema.html b/docs/db-schema.html index 55cdfdd..21cb6ee 100644 --- a/docs/db-schema.html +++ b/docs/db-schema.html @@ -432,7 +432,7 @@ reviewer_idBIGINT UNSIGNED是NULL审核人 statusENUM('draft','pending','approved','rejected')否draft order_dateDATE否— -total_amountDECIMAL(16,2)否0 +cost_totalDECIMAL(16,2)否0应付合计=Σ总进价(2026-07 消歧,旧列 total_amount 弃用待删) reviewed_atDATETIME是NULL custom_fieldsJSON是NULL remarkVARCHAR(500)是NULL @@ -456,8 +456,8 @@ seriesVARCHAR(100)是NULL系列(历史导入快照) specVARCHAR(100)是NULL规格(历史导入快照) quantityDECIMAL(12,3)否—数量 -unit_priceDECIMAL(16,2)否0单价 -total_priceDECIMAL(16,2)否0 +cost_priceDECIMAL(16,2)否0进价·单瓶(旧列 unit_price 弃用待删) +cost_amountDECIMAL(16,2)否0总进价=quantity×cost_price(旧列 total_price 弃用待删) batch_noVARCHAR(50)是NULL批次号 production_dateDATE是NULL生产日期 expire_dateDATE是NULL有效期 @@ -482,7 +482,8 @@ reviewer_idBIGINT UNSIGNED是NULL statusENUM('draft','pending','approved','rejected')否draft order_dateDATE否— -total_amountDECIMAL(16,2)否0 +sale_totalDECIMAL(16,2)否0应收合计=Σ售价小计(旧列 total_amount 弃用待删) +profit_totalDECIMAL(16,2)否0总利润=Σ(售价>0?(售价-成本)×数量:0),建单落库、确认售价/进价联动重算;仅管理员可见(operator 响应抹零) reviewed_atDATETIME是NULL custom_fieldsJSON是NULL remarkVARCHAR(500)是NULL @@ -494,7 +495,7 @@

stock_out_items 出库单明细

-
当前问题相关:出库行快照 batch_no/production_date 在 App 建单时常为空(选货按 product 聚合、未带批次)。打印回退到 products 主记录——若该 product 也没填,就显示空白,但库存页仍能显示(取自入库明细)。unit_price=成本、sale_price=售价。
+
当前问题相关:出库行快照 batch_no/production_date 在 App 建单时常为空(选货按 product 聚合、未带批次)。打印回退到 products 主记录——若该 product 也没填,就显示空白,但库存页仍能显示(取自入库明细)。cost_price=成本、sale_price=售价(2026-07 已消歧改名)。
@@ -506,9 +507,10 @@ - + - + +
列名类型可空默认说明
id PKBIGINT UNSIGNEDAUTO_INC
seriesVARCHAR(100)NULL系列(历史导入快照)
specVARCHAR(100)NULL规格(历史导入快照)
quantityDECIMAL(12,3)
unit_priceDECIMAL(16,2)0成本单价(入库成本快照)
cost_priceDECIMAL(16,2)0成本单价·入库成本快照(旧列 unit_price 弃用待删;仅管理员可见)
sale_priceDECIMAL(16,2)0售价(出库实际销售单价,应收按售价×数量)
total_priceDECIMAL(16,2)0成本小计(unit_price×quantity)
cost_amountDECIMAL(16,2)0成本小计=quantity×cost_price(旧列弃用待删;仅管理员可见)
sale_amountDECIMAL(16,2)0售价小计=quantity×sale_price(待定价=0)
batch_noVARCHAR(50)NULL批次号
production_dateDATENULL生产日期
custom_fieldsJSONNULL