Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d5e8c8be9 | |||
| 85a6eb9150 | |||
| 979ca3c83b | |||
| 4fd0bb8b83 | |||
| 607b8aa763 | |||
| 0b5e27b6b9 |
@@ -170,6 +170,7 @@
|
||||
|
||||
<div class="form-foot">
|
||||
<span class="rows-n" id="rowsN">明细 0 行</span>
|
||||
<div class="total" id="profitWrap" style="display:none">合计利润 <b id="grandProfit">¥0.00</b></div>
|
||||
<div class="total">合计金额 <b id="grandTotal">¥0.00</b></div>
|
||||
<div class="foot-act" id="footActions"></div>
|
||||
</div>
|
||||
|
||||
@@ -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 '<span style="color:var(--faint)">—</span>'; const v=rowProfit(r); return `<span style="color:${v<0?'var(--danger)':'var(--success)'}">${fmt(v)}</span>`; }
|
||||
// 入库进价留空/0 = 待定价(暂估),金额显示「待定价」橙色;确认进价后回填
|
||||
function isPending(r){ return mode==='in' && (!r.price || (parseFloat(r.price)||0)<=0); }
|
||||
function amtHTML(r){ return isPending(r) ? '<span class="pending-price">待定价</span>' : 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='<tr>'+cols.map(k=>{ const d=COLDEF[k]; if(k==='idx')return '<th class="gidx">#</th>'; if(k==='act')return '<th class="gact"></th>'; 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')?'单价<small style="color:var(--faint);font-weight:400">(可待定)</small>':(d.label||''); return `<th class="${cls}" ${style}>${lbl}</th>`; }).join('')+'</tr>';
|
||||
let head='<tr>'+cols.map(k=>{ const d=COLDEF[k]; if(k==='idx')return '<th class="gidx">#</th>'; if(k==='act')return '<th class="gact"></th>'; 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')?'进价(单瓶)<small style="color:var(--faint);font-weight:400">(可待定)</small>':((mode==='in'&&IN_LBL[k])||d.label||''); return `<th class="${cls}" ${style}>${lbl}</th>`; }).join('')+'</tr>';
|
||||
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 `<td class="gidx">${i+1}</td>`;
|
||||
if(k==='name') return `<td class="cpad">${comboCellHTML(i,'name',true)}</td>`;
|
||||
if(k==='name'){ const nm=(DICT.names||[]).find(x=>x.id===r.name); const code=(mode==='out'&&nm&&nm.code)?`<div style="color:var(--muted);font-size:var(--fs-body)">${esc(nm.code)}</div>`:''; return `<td class="cpad">${comboCellHTML(i,'name',true)}${code}</td>`; }
|
||||
if(k==='series'||k==='spec'){ if(mode==='out') return `<td class="ro">${esc(idName(d.dict,r[k]))||'<span style="color:var(--faint)">—</span>'}</td>`; return `<td class="cpad">${comboCellHTML(i,k,true)}</td>`; }
|
||||
if(k==='avail'){ const v=r.avail; const c=v==null?'color:var(--faint)':(v<=0?'color:var(--danger)':''); return `<td class="ro num" style="${c}">${v==null?'—':v}</td>`; }
|
||||
if(k==='prodDate'){ const ph=r.prodDate?'':' ph'; return `<td class="cpad"><div class="datefield gci-date ${r._err.has('prodDate')?'error':''} ${ro?'disabled':''}" tabindex="0" data-row="${i}" data-field="prodDate" data-val="${esc(r.prodDate)}"><span class="dval${ph}">${r.prodDate?esc(r.prodDate):'选择日期'}</span><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg></div></td>`; }
|
||||
if(k==='batch') return `<td class="cpad"><input class="gci ${r._err.has('batch')?'error':''}" data-row="${i}" data-field="batch" value="${esc(r.batch)}" placeholder="批次号" ${ro?'disabled':''}></td>`;
|
||||
if(k==='qty') return `<td class="cpad num"><input class="gci ${r._err.has('qty')?'error':''}" inputmode="decimal" data-row="${i}" data-field="qty" value="${esc(r.qty)}" ${ro?'disabled':''}></td>`;
|
||||
if(k==='price') return `<td class="cpad num"><input class="gci ${r._err.has('price')?'error':''}" inputmode="decimal" data-row="${i}" data-field="price" value="${esc(r.price)}" placeholder="0.00" ${ro?'disabled':''}></td>`;
|
||||
if(k==='price'){ if(mode==='out') return `<td class="ro num">${r.price?fmt(parseFloat(r.price)||0):'—'}</td>`; return `<td class="cpad num"><input class="gci ${r._err.has('price')?'error':''}" inputmode="decimal" data-row="${i}" data-field="price" value="${esc(r.price)}" placeholder="0.00" ${ro?'disabled':''}></td>`; }
|
||||
if(k==='sale') return `<td class="cpad num"><input class="gci ${r._err.has('sale')?'error':''}" inputmode="decimal" data-row="${i}" data-field="sale" value="${esc(r.sale)}" placeholder="0.00" ${ro?'disabled':''}></td>`;
|
||||
if(k==='amount') return `<td class="amt num" data-amt="${i}">${amtHTML(r)}</td>`;
|
||||
if(k==='profit') return `<td class="amt num" data-profit="${i}">${profitHTML(r)}</td>`;
|
||||
if(k==='act'){ const exp = mode==='in'?`<svg class="gexp ${r.expanded?'on':''}" title="选填项(产地/保质期/储存/介绍)" onclick="toggleExpand(${i})" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>`:''; return `<td class="gact"><div class="gact-in">${exp}<svg title="复制本行" onclick="copyRow(${i})" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg><svg class="gdel" title="删除" onclick="delRow(${i})" viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a2 2 0 012-2h2a2 2 0 012 2v2M6 7l1 13a2 2 0 002 2h6a2 2 0 002-2l1-13"/></svg></div></td>`; }
|
||||
return '<td></td>';
|
||||
}).join('');
|
||||
@@ -308,9 +314,9 @@ function cardsHTML(){
|
||||
rows+=mrow('可用库存',false,`<span style="color:${r.avail<=0?'var(--danger)':'var(--muted)'}">${r.avail==null?'—':r.avail}</span>`);
|
||||
}
|
||||
rows+=mrow('数量',true,`<input inputmode="decimal" data-row="${i}" data-field="qty" value="${esc(r.qty)}" class="${r._err.has('qty')?'error':''}" ${ro?'disabled':''}>`);
|
||||
rows+=mrow(mode==='in'?'单价(可待定)':'单价',mode!=='in',`<input inputmode="decimal" data-row="${i}" data-field="price" value="${esc(r.price)}" placeholder="${mode==='in'?'留空=待定价':'0.00'}" class="${r._err.has('price')?'error':''}" ${ro?'disabled':''}>`);
|
||||
rows+=mrow('出售价格',true,`<input inputmode="decimal" data-row="${i}" data-field="sale" value="${esc(r.sale)}" placeholder="0.00" class="${r._err.has('sale')?'error':''}" ${ro?'disabled':''}>`);
|
||||
rows+=`<div class="mrow"><label>金额</label><div class="mv amt" data-amt="${i}">${amtHTML(r)}</div></div>`;
|
||||
rows+=mrow(mode==='in'?'进价·单瓶(可待定)':'进价',false,`<input inputmode="decimal" data-row="${i}" data-field="price" value="${esc(r.price)}" placeholder="${mode==='in'?'留空=待定价':'0.00'}" class="${r._err.has('price')?'error':''}" ${ro?'disabled':''}>`);
|
||||
rows+=mrow(mode==='in'?'参考售价':'售价',true,`<input inputmode="decimal" data-row="${i}" data-field="sale" value="${esc(r.sale)}" placeholder="0.00" class="${r._err.has('sale')?'error':''}" ${ro?'disabled':''}>`);
|
||||
rows+= mode==='out' ? `<div class="mrow"><label>利润</label><div class="mv amt" data-profit="${i}">${profitHTML(r)}</div></div>` : `<div class="mrow"><label>总进价</label><div class="mv amt" data-amt="${i}">${amtHTML(r)}</div></div>`;
|
||||
const act=ro?'':`<div class="mact"><svg title="复制" onclick="copyRow(${i})" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 012-2h10"/></svg><svg class="gdel" title="删除" onclick="delRow(${i})" viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a2 2 0 012-2h2a2 2 0 012 2v2M6 7l1 13a2 2 0 002 2h6a2 2 0 002-2l1-13"/></svg></div>`;
|
||||
return `<div class="mcard ${rerr}"><div class="mcard-head"><span class="mt">商品 ${i+1}</span>${sub?`<span class="msub">${esc(sub)}</span>`:''}${act}</div><div class="mcard-body">${rows}</div></div>`;
|
||||
}).join('');
|
||||
@@ -320,8 +326,9 @@ function cardsHTML(){
|
||||
function mrow(label,req,inner){ return `<div class="mrow"><label class="${req?'req':''}">${label}</label><div class="mv">${inner}</div></div>`; }
|
||||
|
||||
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')); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,11 +295,12 @@ function auditBtns(o,idx){
|
||||
return '';
|
||||
}
|
||||
function openDetail(idx){ const o=ORDERS[idx]; document.getElementById('dTitle').textContent=o.no;
|
||||
let lh=`<div class="lines"><div class="lh" style="grid-template-columns:1fr 124px 40px 64px 78px"><span>商品 · 编码</span><span>系列 / 规格</span><span style="text-align:right">数量</span><span style="text-align:right">单价</span><span style="text-align:right">金额</span></div>`;
|
||||
o.lines.forEach(l=>{ const pi=PINFO[l.name]||{}; const pend=(l.price||0)<=0; const priceC=pend?'<span class="pending-price">待定价</span>':money(l.price); const amtC=pend?'<span class="pending-price">待定价</span>':money(l.qty*l.price); lh+=`<div class="lr" style="grid-template-columns:1fr 124px 40px 64px 78px;align-items:start"><span class="lcell"><span class="ln">${l.name}</span><span class="lcode">${pi.code||'—'}</span></span><span class="lcell"><span class="lser">${pi.series||'—'}</span><span class="lspec">${pi.spec||''}</span></span><span style="text-align:right;font-family:var(--font-mono)">${l.qty}</span><span style="text-align:right;font-family:var(--font-mono)">${priceC}</span><span class="amt">${amtC}</span></div>`; }); lh+='</div>';
|
||||
const GRID='1fr 108px 36px 60px 60px 64px';
|
||||
let lh=`<div class="lines"><div class="lh" style="grid-template-columns:${GRID}"><span>商品 · 编码</span><span>系列 / 规格</span><span style="text-align:right">数量</span><span style="text-align:right">成本价</span><span style="text-align:right">售价</span><span style="text-align:right">利润</span></div>`;
|
||||
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?'<span class="pending-price">待定价</span>':money(l.price); const profit=(l.price-cost)*l.qty; const profitC=pend?'<span class="pending-price">待定价</span>':`<span style="color:${profit<0?'var(--danger)':'var(--success)'}">${money(profit)}</span>`; lh+=`<div class="lr" style="grid-template-columns:${GRID};align-items:start"><span class="lcell"><span class="ln">${l.name}</span><span class="lcode">${pi.code||'—'}</span></span><span class="lcell"><span class="lser">${pi.series||'—'}</span><span class="lspec">${pi.spec||''}</span></span><span style="text-align:right;font-family:var(--font-mono)">${l.qty}</span><span style="text-align:right;font-family:var(--font-mono)">${money(cost)}</span><span style="text-align:right;font-family:var(--font-mono)">${saleC}</span><span class="amt">${profitC}</span></div>`; }); lh+='</div>';
|
||||
const pendCount=o.lines.filter(l=>(l.price||0)<=0).length;
|
||||
const auditor=o.auditor?o.auditor:'<span style="color:var(--faint)">—</span>';
|
||||
document.getElementById('drawerBody').innerHTML=`<div class="drow"><span>单据日期</span><b>${o.date}</b></div><div class="drow"><span>${PARTY_LABEL}</span><b>${o.party}</b></div><div class="drow"><span>仓库</span><b>${o.wh}</b></div><div class="drow"><span>商品数</span><b>${o.count}</b></div><div class="drow"><span>出库员</span><b>${o.op}</b></div><div class="drow"><span>审核员</span><b>${auditor}</b></div><div class="drow"><span>状态</span><b><span class="badge b-${o.status}">${o.status}</span>${o.ret?` <span class="badge b-${o.ret}">${o.ret}</span>`:''}</b></div><label style="display:block;font-size:var(--fs-sm);color:var(--muted);margin:16px 0 8px;">出库明细</label>${lh}<div class="ltotal">合计金额 <b>${money(o.amount)}</b></div>`+
|
||||
document.getElementById('drawerBody').innerHTML=`<div class="drow"><span>单据日期</span><b>${o.date}</b></div><div class="drow"><span>${PARTY_LABEL}</span><b>${o.party}</b></div><div class="drow"><span>仓库</span><b>${o.wh}</b></div><div class="drow"><span>商品数</span><b>${o.count}</b></div><div class="drow"><span>出库员</span><b>${o.op}</b></div><div class="drow"><span>审核员</span><b>${auditor}</b></div><div class="drow"><span>状态</span><b><span class="badge b-${o.status}">${o.status}</span>${o.ret?` <span class="badge b-${o.ret}">${o.ret}</span>`:''}</b></div><label style="display:block;font-size:var(--fs-sm);color:var(--muted);margin:16px 0 8px;">出库明细</label>${lh}<div class="ltotal">合计金额 <b>${money(o.amount)}</b></div><div class="ltotal" style="padding-top:4px">合计利润 <b style="color:var(--success)">${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))}</b></div>`+
|
||||
`<div class="dactions"><div class="dact-group"><span class="dact-label">单据审核</span><div class="dact-row">${auditBtns(o,idx)}</div></div>${(o.status==='已审核'&&pendCount>0)?`<div class="dact-group"><span class="dact-label">售价 · ${pendCount} 项待定价</span><div class="dact-row"><div class="btn primary sm" onclick="openConfirmSale(${idx})"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>确认售价</div></div></div>`:''}<div class="dact-group"><span class="dact-label">打印</span><div class="dact-row"><div class="btn ghost sm" onclick="toast('打印标签(原型)')"><svg viewBox="0 0 24 24"><use href="#i-ic49"/></svg>打印标签</div><div class="btn ghost sm" onclick="toast('打印单据(原型)')"><svg viewBox="0 0 24 24"><use href="#i-ic50"/></svg>打印单据</div></div></div></div>`;
|
||||
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'); }
|
||||
|
||||
@@ -5,6 +5,26 @@ 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.0.87] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
- 登录、注册页全新改版:品牌面板 + 表单两栏布局,支持记住我(自动填入最近登录的门店和账号)、密码可见切换,右上角可直接切换主题。
|
||||
- 入库 / 出库管理顶部统计卡数据补齐,支持「近 30 天」口径。
|
||||
- 商品编辑中「从介绍库选择」升级为可搜索下拉,介绍条目多也能快速定位。
|
||||
- 商品图片双击即可全屏查看。
|
||||
|
||||
### 改进
|
||||
- 全局界面统一:弹窗按钮、下拉框、输入框、提示气泡(toast)、图标、等宽字体全部对齐设计规范,三套主题观感一致。
|
||||
- 入库 / 出库列表版式与搜索框对齐设计稿,单号、金额、审核员列显示更清晰。
|
||||
- 服务恢复域名访问(https://jiu.51yanmei.com),连接全程加密。
|
||||
|
||||
### 修复
|
||||
- 只读模式下操作按钮不再可点,提示不再重复弹出。
|
||||
- 往来单位表格错位、地址为空显示「-」的问题。
|
||||
- 结清账款后财务页应收应付未即时刷新的问题。
|
||||
- 供应商下拉搜索无结果时不再出现误导性的「新增」入口。
|
||||
- 下拉菜单出现位置漂移的问题。
|
||||
|
||||
## [1.0.86] - 2026-06-27
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
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.0.86] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
- 出库单新增「总利润」:建单即计算入库,确认售价、确认进价(成本回填)后自动联动重算。
|
||||
- 成本与利润仅管理员可见:操作员查询出库单时,服务端自动隐藏成本价、成本小计与利润。
|
||||
|
||||
### 改进
|
||||
- 出入库价格字段口径统一:进价/成本、售价、各级小计与合计分列存储,彻底消除「单价×数量与金额对不上」的歧义(存量数据启动时自动迁移,旧客户端建单仍兼容)。
|
||||
|
||||
## [1.0.85] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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` 表数量
|
||||
|
||||
@@ -497,7 +497,7 @@ func (ic *importCtx) writeOrder(
|
||||
TenantBase: model.TenantBase{ShopID: ic.shopID},
|
||||
OrderNo: orderNo, Type: orderType, WarehouseID: ic.whID,
|
||||
PartnerID: partnerID, OperatorID: operatorID, CreatorID: creatorID,
|
||||
ReviewerID: reviewerID, Status: status, OrderDate: orderDate, TotalAmount: totalAmount,
|
||||
ReviewerID: reviewerID, Status: status, OrderDate: orderDate, CostTotal: totalAmount,
|
||||
}
|
||||
if err := tx.Create(&o).Error; err != nil {
|
||||
return err
|
||||
@@ -510,8 +510,8 @@ func (ic *importCtx) writeOrder(
|
||||
Series: get(dr, dCols, "系列"),
|
||||
Spec: get(dr, dCols, "规格"),
|
||||
Quantity: parseFloatLoose(get(dr, dCols, "数量")),
|
||||
UnitPrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
TotalPrice: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
CostPrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
CostAmount: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
BatchNo: get(dr, dCols, "批次号"),
|
||||
}
|
||||
it.ProductionDate = parseDatePtr(get(dr, dCols, "生产日期"))
|
||||
@@ -529,7 +529,8 @@ func (ic *importCtx) writeOrder(
|
||||
TenantBase: model.TenantBase{ShopID: ic.shopID},
|
||||
OrderNo: orderNo, Type: orderType, WarehouseID: ic.whID,
|
||||
PartnerID: partnerID, OperatorID: operatorID, CreatorID: creatorID,
|
||||
ReviewerID: reviewerID, Status: status, OrderDate: orderDate, TotalAmount: totalAmount,
|
||||
// 忠实导入:源「金额」合计沿旧行为落应收合计(sale_price=0 待定价)
|
||||
ReviewerID: reviewerID, Status: status, OrderDate: orderDate, SaleTotal: totalAmount,
|
||||
}
|
||||
if err := tx.Create(&o).Error; err != nil {
|
||||
return err
|
||||
@@ -542,8 +543,8 @@ func (ic *importCtx) writeOrder(
|
||||
Series: get(dr, dCols, "系列"),
|
||||
Spec: get(dr, dCols, "规格"),
|
||||
Quantity: parseFloatLoose(get(dr, dCols, "数量")),
|
||||
UnitPrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
TotalPrice: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
CostPrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
CostAmount: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
BatchNo: get(dr, dCols, "批次号"),
|
||||
}
|
||||
it.ProductionDate = parseDatePtr(get(dr, dCols, "生产日期"))
|
||||
|
||||
@@ -408,14 +408,14 @@ func main() {
|
||||
// 财务记录(与已审核出库单对应的应收款,按售价口径)
|
||||
// ═══════════════════════════════════════════════════
|
||||
createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID,
|
||||
"receivable", out1.order.TotalAmount, out1.order.TotalAmount, "stock_out", out1.order.ID,
|
||||
"receivable", out1.order.SaleTotal, out1.order.SaleTotal, "stock_out", out1.order.ID,
|
||||
d(3), "君悦大酒店4月供货应收款")
|
||||
createFinanceRecord(db, shop.ID, partners["CUS002"].ID, admin.ID,
|
||||
"receivable", out2.order.TotalAmount, out2.order.TotalAmount, "stock_out", out2.order.ID,
|
||||
"receivable", out2.order.SaleTotal, out2.order.SaleTotal, "stock_out", out2.order.ID,
|
||||
d(2), "外滩华尔道夫进口酒应收款")
|
||||
// 模拟一笔已收款
|
||||
createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID,
|
||||
"receipt", out1.order.TotalAmount, 0, "stock_out", out1.order.ID,
|
||||
"receipt", out1.order.SaleTotal, 0, "stock_out", out1.order.ID,
|
||||
d(1), "君悦大酒店回款,结清")
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
@@ -556,19 +556,19 @@ func (s *seeder) createStockIn(
|
||||
Series: g.series,
|
||||
Spec: g.spec,
|
||||
Quantity: ln.qty,
|
||||
UnitPrice: ln.price,
|
||||
TotalPrice: ln.qty * ln.price,
|
||||
CostPrice: ln.price,
|
||||
CostAmount: ln.qty * ln.price,
|
||||
BatchNo: ln.batch,
|
||||
ProductionDate: ln.prodDate,
|
||||
Remark: ln.remark,
|
||||
}
|
||||
s.db.Create(&item)
|
||||
total += item.TotalPrice
|
||||
total += item.CostAmount
|
||||
items = append(items, item)
|
||||
prods = append(prods, p)
|
||||
}
|
||||
s.db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
s.db.Model(&o).Update("cost_total", total)
|
||||
o.CostTotal = total
|
||||
|
||||
fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库=%s 行数=%d 金额=%.0f\n",
|
||||
orderNo, status, partnerID, whName, len(lines), total)
|
||||
@@ -604,7 +604,7 @@ func (s *seeder) applyStockInToInventory(r stockInResult) {
|
||||
whIDCopy := r.order.WarehouseID
|
||||
pidCopy := it.ProductID
|
||||
itemIDCopy := it.ID
|
||||
price := it.UnitPrice
|
||||
price := it.CostPrice
|
||||
inv := model.Inventory{
|
||||
ShopID: s.shopID,
|
||||
WarehouseID: &whIDCopy,
|
||||
@@ -664,7 +664,7 @@ func (s *seeder) createStockOut(
|
||||
s.db.Create(&o)
|
||||
|
||||
var (
|
||||
total float64 // 应收 = Σ 售价×数量
|
||||
total, profit float64 // 应收 = Σ 售价×数量;利润 = Σ(售价-成本)×数量
|
||||
items []model.StockOutItem
|
||||
)
|
||||
for _, ln := range lines {
|
||||
@@ -678,18 +678,21 @@ func (s *seeder) createStockOut(
|
||||
Series: p.Series,
|
||||
Spec: p.Spec,
|
||||
Quantity: ln.qty,
|
||||
UnitPrice: p.PurchasePrice, // 成本单价(快照)
|
||||
SalePrice: ln.sale, // 售价
|
||||
TotalPrice: ln.qty * p.PurchasePrice, // 成本小计
|
||||
CostPrice: p.PurchasePrice, // 成本单价(快照)
|
||||
SalePrice: ln.sale, // 售价
|
||||
CostAmount: ln.qty * p.PurchasePrice, // 成本小计
|
||||
SaleAmount: ln.qty * ln.sale, // 售价小计
|
||||
BatchNo: p.BatchNo,
|
||||
Remark: ln.remark,
|
||||
}
|
||||
s.db.Create(&item)
|
||||
total += ln.qty * ln.sale
|
||||
profit += (ln.sale - p.PurchasePrice) * ln.qty
|
||||
items = append(items, item)
|
||||
}
|
||||
s.db.Model(&o).Update("total_amount", total)
|
||||
o.TotalAmount = total
|
||||
s.db.Model(&o).Updates(map[string]interface{}{"sale_total": total, "profit_total": profit})
|
||||
o.SaleTotal = total
|
||||
o.ProfitTotal = profit
|
||||
|
||||
fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 行数=%d 应收=%.0f\n",
|
||||
orderNo, status, partnerID, whID, len(lines), total)
|
||||
|
||||
@@ -200,12 +200,13 @@ func (h *FinanceHandler) Trend(c *gin.Context) {
|
||||
In float64 `json:"in"`
|
||||
Out float64 `json:"out"`
|
||||
}
|
||||
sum := func(table, from, to string) float64 {
|
||||
// 出库=应收合计(sale_total),入库=应付合计(cost_total)——2026-07 定价字段消歧后分列
|
||||
sum := func(table, col, from, to string) float64 {
|
||||
var v float64
|
||||
h.db.Table(table).
|
||||
Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?",
|
||||
shopID, from, to).
|
||||
Select("COALESCE(SUM(total_amount),0)").Scan(&v)
|
||||
Select("COALESCE(SUM(" + col + "),0)").Scan(&v)
|
||||
return v
|
||||
}
|
||||
const f = "2006-01-02"
|
||||
@@ -215,8 +216,8 @@ func (h *FinanceHandler) Trend(c *gin.Context) {
|
||||
mEnd := mStart.AddDate(0, 1, 0)
|
||||
points = append(points, point{
|
||||
Month: mStart.Format("2006-01"),
|
||||
In: sum("stock_out_orders", mStart.Format(f), mEnd.Format(f)),
|
||||
Out: sum("stock_in_orders", mStart.Format(f), mEnd.Format(f)),
|
||||
In: sum("stock_out_orders", "sale_total", mStart.Format(f), mEnd.Format(f)),
|
||||
Out: sum("stock_in_orders", "cost_total", mStart.Format(f), mEnd.Format(f)),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": points})
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestFinanceHandler_Trend(t *testing.T) {
|
||||
OperatorID: user.ID,
|
||||
Status: "approved",
|
||||
OrderDate: model.Date{Time: date},
|
||||
TotalAmount: amount,
|
||||
SaleTotal: amount,
|
||||
}).Error)
|
||||
}
|
||||
mkIn := func(shopID uint64, date time.Time, amount float64) {
|
||||
@@ -46,7 +46,7 @@ func TestFinanceHandler_Trend(t *testing.T) {
|
||||
OperatorID: user.ID,
|
||||
Status: "approved",
|
||||
OrderDate: model.Date{Time: date},
|
||||
TotalAmount: amount,
|
||||
CostTotal: amount,
|
||||
}).Error)
|
||||
}
|
||||
mkOut(shop.ID, thisMonth, 1000)
|
||||
|
||||
@@ -339,8 +339,8 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) {
|
||||
ShopID: shopID,
|
||||
ProductID: prod.ID,
|
||||
Quantity: qty,
|
||||
UnitPrice: price,
|
||||
TotalPrice: total,
|
||||
CostPrice: price,
|
||||
CostAmount: total,
|
||||
BatchNo: batchNo,
|
||||
})
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) {
|
||||
OperatorID: userID,
|
||||
Status: "draft",
|
||||
OrderDate: orderDate,
|
||||
TotalAmount: totalAmount,
|
||||
CostTotal: totalAmount,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&order).Error; err != nil {
|
||||
@@ -442,8 +442,8 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) {
|
||||
ShopID: shopID,
|
||||
ProductID: prod.ID,
|
||||
Quantity: qty,
|
||||
UnitPrice: price,
|
||||
TotalPrice: total,
|
||||
CostPrice: price,
|
||||
CostAmount: total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -461,7 +461,8 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) {
|
||||
OperatorID: userID,
|
||||
Status: "draft",
|
||||
OrderDate: orderDate,
|
||||
TotalAmount: totalAmount,
|
||||
// 导入单沿旧行为:合计=源金额(明细 sale_price=0 待定价,确认售价后按差额调整)
|
||||
SaleTotal: totalAmount,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&order).Error; err != nil {
|
||||
|
||||
@@ -137,7 +137,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
||||
COALESCE(NULLIF(p.spec,''), NULLIF(sii.spec,''), inv.spec, '') AS spec,
|
||||
COALESCE(NULLIF(p.unit,''), inv.unit, '') AS unit,
|
||||
COALESCE(NULLIF(w.name,''), inv.warehouse_name, '') AS warehouse_name,
|
||||
COALESCE(sii.unit_price, inv.unit_price, p.purchase_price) AS unit_price,
|
||||
COALESCE(sii.cost_price, inv.unit_price, p.purchase_price) AS unit_price,
|
||||
p.sale_price AS sale_price,
|
||||
COALESCE(DATE(sii.production_date), DATE(inv.production_date)) AS production_date,
|
||||
COALESCE(NULLIF(sii.batch_no,''), inv.batch_no, '') AS batch_no,
|
||||
@@ -187,13 +187,13 @@ func (h *InventoryHandler) Summary(c *gin.Context) {
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) AS sku_count,
|
||||
COALESCE(SUM(inv.quantity * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price)), 0) AS stock_value,
|
||||
COALESCE(SUM(inv.quantity * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)), 0) AS stock_value,
|
||||
COALESCE(SUM(inv.quantity), 0) AS in_stock_qty,
|
||||
COALESCE(SUM(CASE WHEN inv.quantity <= 0 THEN 1 ELSE 0 END), 0) AS shortage_count,
|
||||
COALESCE(SUM(CASE WHEN inv.quantity > 0 AND p.min_stock IS NOT NULL AND inv.quantity < p.min_stock THEN 1 ELSE 0 END), 0) AS warning_count,
|
||||
COALESCE(SUM(CASE WHEN inv.created_at < ? THEN 1 ELSE 0 END), 0) AS last_month_sku,
|
||||
COALESCE(SUM(CASE WHEN inv.created_at < ? THEN inv.quantity - COALESCE(lg.net, 0) ELSE 0 END), 0) AS last_month_qty,
|
||||
COALESCE(SUM(CASE WHEN inv.created_at < ? THEN (inv.quantity - COALESCE(lg.net, 0)) * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price) ELSE 0 END), 0) AS last_month_value
|
||||
COALESCE(SUM(CASE WHEN inv.created_at < ? THEN (inv.quantity - COALESCE(lg.net, 0)) * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price) ELSE 0 END), 0) AS last_month_value
|
||||
FROM inventories inv
|
||||
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
|
||||
LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package handler
|
||||
|
||||
// 2026-07 定价字段消歧(cost_price/cost_amount/cost_total/sale_amount/sale_total/profit_total)
|
||||
// 的专项回归:建单三值落库、确认售价联动、确认进价→利润联动、operator 响应抹零。
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// 建单即落三值:sale_amount(行)、sale_total、profit_total(单据)
|
||||
func TestStockOutHandler_Create_PricingFields(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PRC1")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
|
||||
prod := testutil.CreateTestProduct(db, shop.ID, "Gin")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100})
|
||||
|
||||
// 成本 355 × 2,售价 400 × 2 —— 即线上暴露口径问题的那组数
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": prod.ID, "quantity": 2.0, "cost_price": 355.0, "sale_price": 400.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
|
||||
var it model.StockOutItem
|
||||
db.Where("order_id = ?", orderID).First(&it)
|
||||
assert.Equal(t, float64(355), it.CostPrice)
|
||||
assert.Equal(t, float64(710), it.CostAmount) // 2×355
|
||||
assert.Equal(t, float64(800), it.SaleAmount) // 2×400
|
||||
var o model.StockOutOrder
|
||||
db.First(&o, orderID)
|
||||
assert.Equal(t, float64(800), o.SaleTotal)
|
||||
assert.Equal(t, float64(90), o.ProfitTotal) // (400-355)×2
|
||||
}
|
||||
|
||||
// 兼容回归:旧客户端发 unit_price(无 cost_price)→ 回落生效
|
||||
func TestStockOutHandler_Create_LegacyUnitPriceKey(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PRC2")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
|
||||
prod := testutil.CreateTestProduct(db, shop.ID, "Rum")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 10})
|
||||
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": prod.ID, "quantity": 3.0, "unit_price": 20.0, "sale_price": 25.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
var it model.StockOutItem
|
||||
db.Where("order_id = ?", extractID(w)).First(&it)
|
||||
assert.Equal(t, float64(20), it.CostPrice)
|
||||
assert.Equal(t, float64(60), it.CostAmount)
|
||||
}
|
||||
|
||||
// 确认售价:sale_price/sale_amount/sale_total/profit_total 四值联动重算
|
||||
func TestStockOutHandler_ConfirmSale_RecalcsAmounts(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PRC3")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
|
||||
prod := testutil.CreateTestProduct(db, shop.ID, "Sake")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100})
|
||||
|
||||
// 待定价出库:成本 5 × 10
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": prod.ID, "quantity": 10.0, "cost_price": 5.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
|
||||
|
||||
var it model.StockOutItem
|
||||
db.Where("order_id = ?", orderID).First(&it)
|
||||
assert.Equal(t, float64(0), it.SaleAmount) // 待定价
|
||||
|
||||
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/confirm-sale", orderID), token, map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"item_id": it.ID, "sale_price": 8.0}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var got model.StockOutItem
|
||||
db.First(&got, it.ID)
|
||||
assert.Equal(t, float64(8), got.SalePrice)
|
||||
assert.Equal(t, float64(80), got.SaleAmount)
|
||||
var o model.StockOutOrder
|
||||
db.First(&o, orderID)
|
||||
assert.Equal(t, float64(80), o.SaleTotal)
|
||||
assert.Equal(t, float64(30), o.ProfitTotal) // (8-5)×10
|
||||
}
|
||||
|
||||
// 确认进价(成本回填)后,受影响出库单的 profit_total 联动重算
|
||||
func TestConfirmStockInCost_RecalcsStockOutProfit(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PRC4")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
// 0 价暂估入库(新建独立产品)→ 审核
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_name": "调货茅台", "series": "普通", "spec": "500ml", "quantity": 10.0, "cost_price": 0.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
inOrderID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inOrderID), token, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inOrderID), token, nil)
|
||||
|
||||
var inItem model.StockInItem
|
||||
require.NoError(t, db.Where("order_id = ?", inOrderID).First(&inItem).Error)
|
||||
|
||||
// 以 0 成本出库 2 瓶、售价 10 → 审核;利润此刻 = (10-0)×2 = 20
|
||||
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": inItem.ProductID, "quantity": 2.0, "cost_price": 0.0, "sale_price": 10.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
outOrderID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", outOrderID), token, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", outOrderID), token, nil)
|
||||
|
||||
var beforeO model.StockOutOrder
|
||||
db.First(&beforeO, outOrderID)
|
||||
assert.Equal(t, float64(20), beforeO.ProfitTotal)
|
||||
|
||||
// 确认进价 4 → 出库成本快照回填 → 利润重算 (10-4)×2 = 12
|
||||
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inOrderID), token, map[string]interface{}{
|
||||
"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 4.0}},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var outItem model.StockOutItem
|
||||
db.Where("order_id = ?", outOrderID).First(&outItem)
|
||||
assert.Equal(t, float64(4), outItem.CostPrice)
|
||||
assert.Equal(t, float64(8), outItem.CostAmount)
|
||||
var afterO model.StockOutOrder
|
||||
db.First(&afterO, outOrderID)
|
||||
assert.Equal(t, float64(12), afterO.ProfitTotal)
|
||||
}
|
||||
|
||||
// 成本/利润仅管理员可见:operator 的 List/Get 响应中 cost_price/cost_amount/profit_total 被抹零
|
||||
func TestStockOutHandler_CostHiddenFromOperator(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PRC5")
|
||||
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
op := testutil.CreateTestUser(db, shop.ID, "op1", "pass", "operator")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
|
||||
prod := testutil.CreateTestProduct(db, shop.ID, "Vermouth")
|
||||
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
|
||||
opToken := getAuthToken(op.ID, shop.ID, "operator")
|
||||
r := setupProtectedRouter(db)
|
||||
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 10})
|
||||
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", adminToken, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": prod.ID, "quantity": 2.0, "cost_price": 30.0, "sale_price": 50.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
|
||||
// 管理员可见成本与利润
|
||||
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), adminToken, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
data := parseResponse(w)["data"].(map[string]interface{})
|
||||
assert.Equal(t, float64(40), data["profit_total"])
|
||||
items := data["items"].([]interface{})
|
||||
assert.Equal(t, float64(30), items[0].(map[string]interface{})["cost_price"])
|
||||
|
||||
// operator:成本/利润抹零,售价照常
|
||||
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), opToken, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
data = parseResponse(w)["data"].(map[string]interface{})
|
||||
assert.Equal(t, float64(0), data["profit_total"])
|
||||
items = data["items"].([]interface{})
|
||||
line := items[0].(map[string]interface{})
|
||||
assert.Equal(t, float64(0), line["cost_price"])
|
||||
assert.Equal(t, float64(0), line["cost_amount"])
|
||||
assert.Equal(t, float64(50), line["sale_price"])
|
||||
assert.Equal(t, float64(100), line["sale_amount"])
|
||||
}
|
||||
@@ -251,11 +251,11 @@ func (h *ProductHandler) PriceHistory(c *gin.Context) {
|
||||
points := make([]pricePoint, 0)
|
||||
// 守多租户:order 的 shop_id 必须等于当前店;只取已审核单的正单价。
|
||||
h.db.Raw(`
|
||||
SELECT o.order_date AS date, sii.unit_price AS price
|
||||
SELECT o.order_date AS date, sii.cost_price AS price
|
||||
FROM stock_in_items sii
|
||||
JOIN stock_in_orders o ON o.id = sii.order_id
|
||||
WHERE sii.product_id = ? AND o.shop_id = ? AND o.status = 'approved'
|
||||
AND sii.unit_price > 0
|
||||
AND sii.cost_price > 0
|
||||
ORDER BY o.order_date DESC, sii.id DESC
|
||||
LIMIT 20`, id, shopID).Scan(&points)
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ func TestProductHandler_PriceHistory(t *testing.T) {
|
||||
require.NoError(t, db.Create(o).Error)
|
||||
require.NoError(t, db.Create(&model.StockInItem{
|
||||
OrderID: o.ID, ShopID: shop.ID, ProductID: product.ID,
|
||||
UnitPrice: price, Quantity: 1,
|
||||
CostPrice: price, Quantity: 1,
|
||||
}).Error)
|
||||
}
|
||||
mkOrder("RK-PH-1", 60, 2580, "approved")
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) {
|
||||
require.NoError(t, db.Where("order_id = ?", inID).First(&inItem).Error)
|
||||
pid := inItem.ProductID // 序列号模型:入库为该行新建独立 product
|
||||
|
||||
// 库存批次成本待定(UnitPrice 为 NULL)
|
||||
// 库存批次成本待定(CostPrice 为 NULL)
|
||||
var inv model.Inventory
|
||||
require.NoError(t, db.Where("shop_id = ? AND stock_in_item_id = ?", shop.ID, inItem.ID).First(&inv).Error)
|
||||
assert.Nil(t, inv.UnitPrice, "0 价入库时库存成本应为待定(NULL)")
|
||||
@@ -58,8 +58,8 @@ func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) {
|
||||
|
||||
// 1. 入库明细成本回填
|
||||
db.First(&inItem, inItem.ID)
|
||||
assert.Equal(t, 50.0, inItem.UnitPrice)
|
||||
assert.Equal(t, 500.0, inItem.TotalPrice)
|
||||
assert.Equal(t, 50.0, inItem.CostPrice)
|
||||
assert.Equal(t, 500.0, inItem.CostAmount)
|
||||
|
||||
// 2. 剩余库存批次成本回填(6 件)
|
||||
db.First(&inv, inv.ID)
|
||||
@@ -69,18 +69,18 @@ func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) {
|
||||
// 3. 已出库行成本快照回填,售价/应收不变
|
||||
var outItem model.StockOutItem
|
||||
require.NoError(t, db.Where("order_id = ?", outID).First(&outItem).Error)
|
||||
assert.Equal(t, 50.0, outItem.UnitPrice, "已出库成本应回填")
|
||||
assert.Equal(t, 200.0, outItem.TotalPrice, "成本小计 = 50×4")
|
||||
assert.Equal(t, 50.0, outItem.CostPrice, "已出库成本应回填")
|
||||
assert.Equal(t, 200.0, outItem.CostAmount, "成本小计 = 50×4")
|
||||
assert.Equal(t, 80.0, outItem.SalePrice, "售价不应被改动")
|
||||
|
||||
var outOrder model.StockOutOrder
|
||||
db.First(&outOrder, outID)
|
||||
assert.Equal(t, 320.0, outOrder.TotalAmount, "应收按售价 4×80,确认进价不影响")
|
||||
assert.Equal(t, 320.0, outOrder.SaleTotal, "应收按售价 4×80,确认进价不影响")
|
||||
|
||||
// 4. 入库单总额按差额重算:0 → 500
|
||||
var inOrder model.StockInOrder
|
||||
db.First(&inOrder, inID)
|
||||
assert.Equal(t, 500.0, inOrder.TotalAmount)
|
||||
assert.Equal(t, 500.0, inOrder.CostTotal)
|
||||
|
||||
// 5. 应付差额调整流水:+500(balance 是应收应付混合滚动总账:0 入 + 320 应收 + 500 = 820)
|
||||
var adj model.FinanceRecord
|
||||
@@ -175,11 +175,11 @@ func TestConfirmCost_RepeatableByDiff(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
db.First(&inItem, inItem.ID)
|
||||
assert.Equal(t, 60.0, inItem.UnitPrice)
|
||||
assert.Equal(t, 60.0, inItem.CostPrice)
|
||||
|
||||
var inOrder model.StockInOrder
|
||||
db.First(&inOrder, inID)
|
||||
assert.Equal(t, 600.0, inOrder.TotalAmount, "总额 = 60×10")
|
||||
assert.Equal(t, 600.0, inOrder.CostTotal, "总额 = 60×10")
|
||||
|
||||
// 两条调整流水:+500 与 +100,余额滚动到 600
|
||||
var adjs []model.FinanceRecord
|
||||
|
||||
@@ -193,7 +193,7 @@ func (h *StockInHandler) Summary(c *gin.Context) {
|
||||
}
|
||||
h.db.Model(&model.StockInOrder{}).
|
||||
Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to).
|
||||
Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r)
|
||||
Select("COUNT(*) AS cnt, COALESCE(SUM(cost_total),0) AS amt").Scan(&r)
|
||||
return r.Cnt, r.Amt
|
||||
}
|
||||
var s stockSummary
|
||||
@@ -258,19 +258,23 @@ func (h *StockInHandler) Create(c *gin.Context) {
|
||||
for i := range req.Items {
|
||||
it := &req.Items[i]
|
||||
it.ShopID = shopID
|
||||
// 兼容旧客户端(≤1.0.87):unit_price 回落为 cost_price(新 key 优先)
|
||||
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
|
||||
it.CostPrice = *it.LegacyUnitPrice
|
||||
}
|
||||
if it.BatchNo == "" {
|
||||
it.BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1)
|
||||
}
|
||||
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice, it.SalePrice)
|
||||
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
it.ProductID = prod.ID
|
||||
it.ProductCode = prod.Code // 保留快照,兼容现有查询(瘦身阶段再去)
|
||||
it.TotalPrice = it.Quantity * it.UnitPrice
|
||||
total += it.TotalPrice
|
||||
it.CostAmount = it.Quantity * it.CostPrice
|
||||
total += it.CostAmount
|
||||
}
|
||||
req.TotalAmount = total
|
||||
req.CostTotal = total
|
||||
return tx.Create(&req).Error
|
||||
})
|
||||
if err != nil {
|
||||
@@ -316,25 +320,28 @@ func (h *StockInHandler) Update(c *gin.Context) {
|
||||
it := &req.Items[i]
|
||||
it.ShopID = shopID
|
||||
it.OrderID = order.ID
|
||||
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
|
||||
it.CostPrice = *it.LegacyUnitPrice
|
||||
}
|
||||
if it.BatchNo == "" {
|
||||
it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1)
|
||||
}
|
||||
// 编辑草稿:旧明细已删,每条按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理)
|
||||
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice, it.SalePrice)
|
||||
prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
it.ProductID = prod.ID
|
||||
it.ProductCode = prod.Code
|
||||
it.TotalPrice = it.Quantity * it.UnitPrice
|
||||
total += it.TotalPrice
|
||||
it.CostAmount = it.Quantity * it.CostPrice
|
||||
total += it.CostAmount
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"warehouse_id": req.WarehouseID,
|
||||
"partner_id": req.PartnerID,
|
||||
"order_date": req.OrderDate,
|
||||
"remark": req.Remark,
|
||||
"total_amount": total,
|
||||
"cost_total": total,
|
||||
}
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -402,6 +402,7 @@ func TestStockInHandler_TotalAmount(t *testing.T) {
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
|
||||
"warehouse_id": warehouse.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
// 故意发旧 key unit_price:v1.0.87 及之前客户端的兼容回归(回落到 cost_price)
|
||||
"items": []map[string]interface{}{
|
||||
{"product_id": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50
|
||||
{"product_id": product2.ID, "quantity": 3.0, "unit_price": 20.0}, // 60
|
||||
@@ -409,7 +410,7 @@ func TestStockInHandler_TotalAmount(t *testing.T) {
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
data := parseResponse(w)["data"].(map[string]interface{})
|
||||
assert.Equal(t, float64(110), data["total_amount"])
|
||||
assert.Equal(t, float64(110), data["cost_total"])
|
||||
}
|
||||
|
||||
// 回归:编辑草稿入库单后 total_amount 必须按新明细重算(此前 Update 漏了累加,编辑后被清成 0)。
|
||||
@@ -437,8 +438,8 @@ func TestStockInHandler_UpdateRecomputesTotal(t *testing.T) {
|
||||
"warehouse_id": warehouse.ID,
|
||||
"order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{
|
||||
{"product_name": "茅台2009", "series": "大件", "spec": "500ml", "quantity": 12.0, "unit_price": 3100.0},
|
||||
{"product_name": "茅台十五年", "series": "大件", "spec": "500ml", "quantity": 1.0, "unit_price": 4500.0},
|
||||
{"product_name": "茅台2009", "series": "大件", "spec": "500ml", "quantity": 12.0, "cost_price": 3100.0},
|
||||
{"product_name": "茅台十五年", "series": "大件", "spec": "500ml", "quantity": 1.0, "cost_price": 4500.0},
|
||||
},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
@@ -447,7 +448,7 @@ func TestStockInHandler_UpdateRecomputesTotal(t *testing.T) {
|
||||
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
data := parseResponse(w)["data"].(map[string]interface{})
|
||||
assert.Equal(t, float64(41700), data["total_amount"], "编辑后金额应按新明细重算")
|
||||
assert.Equal(t, float64(41700), data["cost_total"], "编辑后金额应按新明细重算")
|
||||
}
|
||||
|
||||
func TestStockInHandler_NoAuth(t *testing.T) {
|
||||
|
||||
@@ -27,6 +27,22 @@ func NewStockOutHandler(db *gorm.DB, svc *service.StockService) *StockOutHandler
|
||||
}
|
||||
|
||||
// List GET /api/v1/stock-out/orders
|
||||
// stripStockOutCost 服务端兜底:成本/利润仅管理员可见(2026-07 用户拍板)。
|
||||
// operator/readonly 的 List/Get 响应把 cost_price/cost_amount/profit_total 抹零,
|
||||
// 防止非管理员通过抓包看到成本与利润(前端同时隐藏对应列)。
|
||||
func stripStockOutCost(role string, orders []model.StockOutOrder) {
|
||||
if role == "admin" || role == "superadmin" {
|
||||
return
|
||||
}
|
||||
for i := range orders {
|
||||
orders[i].ProfitTotal = 0
|
||||
for j := range orders[i].Items {
|
||||
orders[i].Items[j].CostPrice = 0
|
||||
orders[i].Items[j].CostAmount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StockOutHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
@@ -78,6 +94,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("order_date DESC, id DESC").Find(&orders)
|
||||
|
||||
stripStockOutCost(middleware.GetRole(c), orders)
|
||||
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
@@ -92,7 +109,7 @@ func (h *StockOutHandler) Summary(c *gin.Context) {
|
||||
}
|
||||
h.db.Model(&model.StockOutOrder{}).
|
||||
Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to).
|
||||
Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r)
|
||||
Select("COUNT(*) AS cnt, COALESCE(SUM(sale_total),0) AS amt").Scan(&r)
|
||||
return r.Cnt, r.Amt
|
||||
}
|
||||
var s stockSummary
|
||||
@@ -150,7 +167,9 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, order)
|
||||
one := []model.StockOutOrder{order}
|
||||
stripStockOutCost(middleware.GetRole(c), one)
|
||||
util.RespondSuccess(c, one[0])
|
||||
}
|
||||
|
||||
// Create POST /api/v1/stock-out/orders
|
||||
@@ -231,14 +250,24 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
||||
}
|
||||
req.OrderNo = orderNo
|
||||
|
||||
var total float64
|
||||
var total, profit float64
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].Quantity * req.Items[i].SalePrice
|
||||
it := &req.Items[i]
|
||||
it.ShopID = shopID
|
||||
// 兼容旧客户端(≤1.0.87):unit_price 回落为 cost_price(新 key 优先)
|
||||
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
|
||||
it.CostPrice = *it.LegacyUnitPrice
|
||||
}
|
||||
// 成本小计 / 售价小计分列;应收(SaleTotal)与总利润按售价口径
|
||||
it.CostAmount = it.Quantity * it.CostPrice
|
||||
it.SaleAmount = it.Quantity * it.SalePrice
|
||||
total += it.SaleAmount
|
||||
if it.SalePrice > 0 {
|
||||
profit += (it.SalePrice - it.CostPrice) * it.Quantity
|
||||
}
|
||||
}
|
||||
req.TotalAmount = total
|
||||
req.SaleTotal = total
|
||||
req.ProfitTotal = profit
|
||||
|
||||
if err := fillStockOutItemSnapshots(h.db, shopID, req.Items); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -284,12 +313,20 @@ func (h *StockOutHandler) Update(c *gin.Context) {
|
||||
return err
|
||||
}
|
||||
var total float64
|
||||
var profit float64
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
req.Items[i].OrderID = order.ID
|
||||
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].Quantity * req.Items[i].SalePrice
|
||||
it := &req.Items[i]
|
||||
it.ShopID = shopID
|
||||
it.OrderID = order.ID
|
||||
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
|
||||
it.CostPrice = *it.LegacyUnitPrice
|
||||
}
|
||||
it.CostAmount = it.Quantity * it.CostPrice
|
||||
it.SaleAmount = it.Quantity * it.SalePrice
|
||||
total += it.SaleAmount
|
||||
if it.SalePrice > 0 {
|
||||
profit += (it.SalePrice - it.CostPrice) * it.Quantity
|
||||
}
|
||||
}
|
||||
if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil {
|
||||
return err
|
||||
@@ -299,7 +336,8 @@ func (h *StockOutHandler) Update(c *gin.Context) {
|
||||
"partner_id": req.PartnerID,
|
||||
"order_date": req.OrderDate,
|
||||
"remark": req.Remark,
|
||||
"total_amount": total,
|
||||
"sale_total": total,
|
||||
"profit_total": profit,
|
||||
}
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -639,7 +639,7 @@ func TestStockOutHandler_ConfirmSale(t *testing.T) {
|
||||
assert.Equal(t, float64(8), got.SalePrice)
|
||||
var order model.StockOutOrder
|
||||
db.First(&order, orderID)
|
||||
assert.Equal(t, float64(80), order.TotalAmount) // 8 × 10
|
||||
assert.Equal(t, float64(80), order.SaleTotal) // 8 × 10
|
||||
var fr model.FinanceRecord
|
||||
require.NoError(t, db.Where("shop_id = ? AND ref_type = ?", shop.ID, "stock_out_sale_adjust").First(&fr).Error)
|
||||
assert.Equal(t, float64(80), fr.Amount)
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestStockReturn_StockOut_OwnAllowed(t *testing.T) {
|
||||
// 应收按售价口径:TotalAmount = 15 × 25 = 375(不是成本 15×20)
|
||||
var soOrder model.StockOutOrder
|
||||
require.NoError(t, db.First(&soOrder, orderID).Error)
|
||||
assert.Equal(t, float64(375), soOrder.TotalAmount)
|
||||
assert.Equal(t, float64(375), soOrder.SaleTotal)
|
||||
var recv model.FinanceRecord
|
||||
require.NoError(t, db.Where("shop_id = ? AND type = 'receivable' AND ref_type = 'stock_out'", shop.ID).First(&recv).Error)
|
||||
assert.Equal(t, float64(375), recv.Amount, "应收应按售价×数量")
|
||||
|
||||
@@ -13,10 +13,11 @@ type StockInOrder struct {
|
||||
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
||||
CreatorID *uint64 `json:"creator_id"`
|
||||
ReviewerID *uint64 `json:"reviewer_id"`
|
||||
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||
// 应付合计 = Σ 明细总进价(2026-07 定价字段消歧:旧列 total_amount 弃用,启动回填)
|
||||
CostTotal float64 `gorm:"column:cost_total;type:decimal(16,2);default:0" json:"cost_total"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
// 退单状态:none=无 / partial=部分退单 / full=已全退(仅 approved 单可退)
|
||||
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
@@ -40,11 +41,15 @@ type StockInItem struct {
|
||||
ProductName string `gorm:"size:255" json:"product_name"`
|
||||
Series string `gorm:"size:100" json:"series"`
|
||||
Spec string `gorm:"size:100" json:"spec"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||
// 建议售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。
|
||||
SalePrice float64 `gorm:"-" json:"sale_price"`
|
||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
// 进价(单瓶成本)。2026-07 定价字段消歧:unit_price → cost_price(旧列弃用,启动回填)
|
||||
CostPrice float64 `gorm:"column:cost_price;type:decimal(16,2);default:0" json:"cost_price"`
|
||||
// 兼容一版:旧客户端(≤1.0.87)建单仍发 unit_price,入参回落到 CostPrice(不落库不出参)
|
||||
LegacyUnitPrice *float64 `gorm:"-" json:"unit_price,omitempty"`
|
||||
// 参考售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。
|
||||
SalePrice float64 `gorm:"-" json:"sale_price"`
|
||||
// 总进价 = quantity × cost_price(旧列 total_price 弃用)
|
||||
CostAmount float64 `gorm:"column:cost_amount;type:decimal(16,2);default:0" json:"cost_amount"`
|
||||
// 已退数量:0=未退,=Quantity 表示整行已退单(整行退,不做部分数量)
|
||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||
@@ -67,9 +72,13 @@ type StockOutOrder struct {
|
||||
OperatorID uint64 `gorm:"not null" json:"operator_id"`
|
||||
CreatorID *uint64 `json:"creator_id"`
|
||||
ReviewerID *uint64 `json:"reviewer_id"`
|
||||
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
||||
Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"`
|
||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||
// 应收合计 = Σ 明细售价小计(2026-07 定价字段消歧:旧列 total_amount 弃用,启动回填)
|
||||
SaleTotal float64 `gorm:"column:sale_total;type:decimal(16,2);default:0" json:"sale_total"`
|
||||
// 总利润 = Σ(sale_price>0 ? (sale_price-cost_price)×qty : 0),建单落库,
|
||||
// 确认售价 / 确认进价(成本回填)时联动重算
|
||||
ProfitTotal float64 `gorm:"column:profit_total;type:decimal(16,2);default:0" json:"profit_total"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
// 退单状态:none / partial / full
|
||||
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
||||
@@ -94,11 +103,17 @@ type StockOutItem struct {
|
||||
ProductName string `gorm:"size:255" json:"product_name"`
|
||||
Series string `gorm:"size:100" json:"series"`
|
||||
Spec string `gorm:"size:100" json:"spec"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||
// 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 UnitPrice(入库成本) 区分
|
||||
SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"`
|
||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
// 成本单价(入库成本快照)。2026-07 消歧:unit_price → cost_price(旧列弃用)
|
||||
CostPrice float64 `gorm:"column:cost_price;type:decimal(16,2);default:0" json:"cost_price"`
|
||||
// 兼容一版:旧客户端(≤1.0.87)建单仍发 unit_price,入参回落到 CostPrice(不落库不出参)
|
||||
LegacyUnitPrice *float64 `gorm:"-" json:"unit_price,omitempty"`
|
||||
// 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 CostPrice(入库成本) 区分
|
||||
SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"`
|
||||
// 成本小计 = quantity × cost_price(旧列 total_price 弃用)
|
||||
CostAmount float64 `gorm:"column:cost_amount;type:decimal(16,2);default:0" json:"cost_amount"`
|
||||
// 售价小计 = quantity × sale_price(待定价=0;确认售价时重算)
|
||||
SaleAmount float64 `gorm:"column:sale_amount;type:decimal(16,2);default:0" json:"sale_amount"`
|
||||
// 已退数量:0=未退,=Quantity 表示整行已退单
|
||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
// BackfillPricingColumns 2026-07 定价字段消歧的一次性数据迁移(幂等,可反复执行):
|
||||
// - stock_in_items / stock_out_items:unit_price→cost_price、total_price→cost_amount
|
||||
// - stock_in_orders:total_amount→cost_total;stock_out_orders:total_amount→sale_total
|
||||
// - stock_out_items.sale_amount = sale_price×quantity(待定价行保持 0)
|
||||
// - stock_out_orders.profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×qty : 0)
|
||||
//
|
||||
// 只在新列为 0 且旧列非 0 时拷贝,不修改旧列(旧列观察一版后手动 DROP)。
|
||||
// profit_total 以「为 0 且存在已定价明细」为待回填判据——利润恰为 0 的单重算一次
|
||||
// 结果不变,幂等。全新安装(schema.sql 已无旧列)时整体跳过。
|
||||
func BackfillPricingColumns(db *gorm.DB) {
|
||||
// 旧列存在性守卫:四张表的旧列是同批产生的,抽查一张即可
|
||||
// (model 已无 UnitPrice 字段,HasColumn 会按原始列名探测)
|
||||
if !db.Migrator().HasColumn(&model.StockOutItem{}, "unit_price") {
|
||||
return
|
||||
}
|
||||
stmts := []string{
|
||||
`UPDATE stock_in_items SET cost_price = unit_price WHERE cost_price = 0 AND unit_price <> 0`,
|
||||
`UPDATE stock_in_items SET cost_amount = total_price WHERE cost_amount = 0 AND total_price <> 0`,
|
||||
`UPDATE stock_in_orders SET cost_total = total_amount WHERE cost_total = 0 AND total_amount <> 0`,
|
||||
`UPDATE stock_out_items SET cost_price = unit_price WHERE cost_price = 0 AND unit_price <> 0`,
|
||||
`UPDATE stock_out_items SET cost_amount = total_price WHERE cost_amount = 0 AND total_price <> 0`,
|
||||
`UPDATE stock_out_items SET sale_amount = sale_price * quantity WHERE sale_amount = 0 AND sale_price > 0`,
|
||||
`UPDATE stock_out_orders SET sale_total = total_amount WHERE sale_total = 0 AND total_amount <> 0`,
|
||||
`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 profit_total = 0 AND EXISTS (
|
||||
SELECT 1 FROM stock_out_items i2
|
||||
WHERE i2.order_id = stock_out_orders.id AND i2.sale_price > 0
|
||||
)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
res := db.Exec(q)
|
||||
if res.Error != nil {
|
||||
log.Printf("backfill pricing columns failed (%.60s...): %v", q, res.Error)
|
||||
} else if res.RowsAffected > 0 {
|
||||
log.Printf("backfill pricing: %d rows (%.60s...)", res.RowsAffected, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// BackfillPricingColumns:旧列(unit_price/total_price/total_amount)拷入新列,幂等。
|
||||
// 测试库 DDL 已是新列,这里手动补建旧列模拟升级前的生产库形态。
|
||||
func TestBackfillPricingColumns_Idempotent(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
for _, ddl := range []string{
|
||||
`ALTER TABLE stock_in_items ADD COLUMN unit_price REAL DEFAULT 0`,
|
||||
`ALTER TABLE stock_in_items ADD COLUMN total_price REAL DEFAULT 0`,
|
||||
`ALTER TABLE stock_in_orders ADD COLUMN total_amount REAL DEFAULT 0`,
|
||||
`ALTER TABLE stock_out_items ADD COLUMN unit_price REAL DEFAULT 0`,
|
||||
`ALTER TABLE stock_out_items ADD COLUMN total_price REAL DEFAULT 0`,
|
||||
`ALTER TABLE stock_out_orders ADD COLUMN total_amount REAL DEFAULT 0`,
|
||||
} {
|
||||
require.NoError(t, db.Exec(ddl).Error)
|
||||
}
|
||||
|
||||
shop := testutil.CreateTestShop(db, "MIG1")
|
||||
|
||||
// 升级前形态:只有旧列有值(新列 0)
|
||||
inOrder := model.StockInOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "R1", WarehouseID: 1, OperatorID: 1, Status: "approved"}
|
||||
require.NoError(t, db.Create(&inOrder).Error)
|
||||
db.Exec(`UPDATE stock_in_orders SET total_amount = 500 WHERE id = ?`, inOrder.ID)
|
||||
inItem := model.StockInItem{OrderID: inOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 10}
|
||||
require.NoError(t, db.Create(&inItem).Error)
|
||||
db.Exec(`UPDATE stock_in_items SET unit_price = 50, total_price = 500 WHERE id = ?`, inItem.ID)
|
||||
|
||||
outOrder := model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "C1", WarehouseID: 1, OperatorID: 1, Status: "approved"}
|
||||
require.NoError(t, db.Create(&outOrder).Error)
|
||||
db.Exec(`UPDATE stock_out_orders SET total_amount = 800 WHERE id = ?`, outOrder.ID)
|
||||
outItem := model.StockOutItem{OrderID: outOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 2, SalePrice: 400}
|
||||
require.NoError(t, db.Create(&outItem).Error)
|
||||
db.Exec(`UPDATE stock_out_items SET unit_price = 355, total_price = 710, sale_amount = 0 WHERE id = ?`, outItem.ID)
|
||||
|
||||
// 待定价出库单:sale_price=0 → sale_amount/profit 均应保持 0
|
||||
pendOrder := model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "C2", WarehouseID: 1, OperatorID: 1, Status: "approved"}
|
||||
require.NoError(t, db.Create(&pendOrder).Error)
|
||||
pendItem := model.StockOutItem{OrderID: pendOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 3}
|
||||
require.NoError(t, db.Create(&pendItem).Error)
|
||||
db.Exec(`UPDATE stock_out_items SET unit_price = 100, total_price = 300 WHERE id = ?`, pendItem.ID)
|
||||
|
||||
BackfillPricingColumns(db)
|
||||
|
||||
var gotIn model.StockInItem
|
||||
db.First(&gotIn, inItem.ID)
|
||||
assert.Equal(t, float64(50), gotIn.CostPrice)
|
||||
assert.Equal(t, float64(500), gotIn.CostAmount)
|
||||
var gotInOrder model.StockInOrder
|
||||
db.First(&gotInOrder, inOrder.ID)
|
||||
assert.Equal(t, float64(500), gotInOrder.CostTotal)
|
||||
|
||||
var gotOut model.StockOutItem
|
||||
db.First(&gotOut, outItem.ID)
|
||||
assert.Equal(t, float64(355), gotOut.CostPrice)
|
||||
assert.Equal(t, float64(710), gotOut.CostAmount)
|
||||
assert.Equal(t, float64(800), gotOut.SaleAmount) // 400×2
|
||||
var gotOutOrder model.StockOutOrder
|
||||
db.First(&gotOutOrder, outOrder.ID)
|
||||
assert.Equal(t, float64(800), gotOutOrder.SaleTotal)
|
||||
assert.Equal(t, float64(90), gotOutOrder.ProfitTotal) // (400-355)×2
|
||||
|
||||
var gotPend model.StockOutItem
|
||||
db.First(&gotPend, pendItem.ID)
|
||||
assert.Equal(t, float64(100), gotPend.CostPrice)
|
||||
assert.Equal(t, float64(0), gotPend.SaleAmount)
|
||||
var gotPendOrder model.StockOutOrder
|
||||
db.First(&gotPendOrder, pendOrder.ID)
|
||||
assert.Equal(t, float64(0), gotPendOrder.ProfitTotal)
|
||||
|
||||
// 幂等:第二次执行不改变结果(人为改一个新列值验证不会被旧值覆盖——新列非 0 即跳过)
|
||||
db.Exec(`UPDATE stock_out_items SET sale_price = 450, sale_amount = 900 WHERE id = ?`, outItem.ID)
|
||||
BackfillPricingColumns(db)
|
||||
var again model.StockOutItem
|
||||
db.First(&again, outItem.ID)
|
||||
assert.Equal(t, float64(900), again.SaleAmount) // 未被回填覆盖
|
||||
var againOrder model.StockOutOrder
|
||||
db.First(&againOrder, outOrder.ID)
|
||||
assert.Equal(t, float64(800), againOrder.SaleTotal) // 已有值不重拷
|
||||
}
|
||||
@@ -63,8 +63,8 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
}
|
||||
|
||||
var unitPricePtr *float64
|
||||
if itemCopy.UnitPrice != 0 {
|
||||
unitPricePtr = &itemCopy.UnitPrice
|
||||
if itemCopy.CostPrice != 0 {
|
||||
unitPricePtr = &itemCopy.CostPrice
|
||||
}
|
||||
|
||||
// 优先使用明细自带快照列(历史导入单的真实商品信息在此),
|
||||
@@ -130,13 +130,13 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
|
||||
// 自动创建应付账款财务记录
|
||||
{
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.CostTotal
|
||||
orderID := order.ID
|
||||
rec := model.FinanceRecord{
|
||||
ShopID: shopID,
|
||||
PartnerID: order.PartnerID,
|
||||
Type: "payable",
|
||||
Amount: order.TotalAmount,
|
||||
Amount: order.CostTotal,
|
||||
Balance: bal,
|
||||
Status: "open",
|
||||
RefType: "stock_in",
|
||||
@@ -172,11 +172,11 @@ type SaleConfirmItem struct {
|
||||
// ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后,
|
||||
// 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内
|
||||
// 前向写补偿:
|
||||
// 1. 入库明细 UnitPrice / TotalPrice 改为真实值
|
||||
// 1. 入库明细 CostPrice / CostAmount 改为真实值
|
||||
// 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值
|
||||
// 3. 该 product 已出库行 StockOutItem.UnitPrice/TotalPrice(成本快照)回填真实值
|
||||
// 3. 该 product 已出库行 StockOutItem.CostPrice/CostAmount(成本快照)回填真实值
|
||||
// (product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收)
|
||||
// 4. 入库单 TotalAmount 按差额重算
|
||||
// 4. 入库单 CostTotal 按差额重算
|
||||
// 5. 对供应商应付按差额补一条调整流水(滚动余额)
|
||||
//
|
||||
// 仅 approved 单可确认(draft 直接编辑即可);权限:管理员/超管。
|
||||
@@ -202,21 +202,22 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
|
||||
now := time.Now()
|
||||
var totalDiff float64
|
||||
var changedProducts []uint64 // 成本被改的商品:其已出库单的利润需联动重算
|
||||
|
||||
for i := range order.Items {
|
||||
it := &order.Items[i]
|
||||
newPrice, ok := want[it.ID]
|
||||
if !ok || newPrice == it.UnitPrice {
|
||||
if !ok || newPrice == it.CostPrice {
|
||||
continue
|
||||
}
|
||||
oldPrice := it.UnitPrice
|
||||
oldPrice := it.CostPrice
|
||||
diff := (newPrice - oldPrice) * it.Quantity
|
||||
totalDiff += diff
|
||||
|
||||
// 1. 入库明细
|
||||
if err := tx.Model(it).Updates(map[string]interface{}{
|
||||
"unit_price": newPrice,
|
||||
"total_price": newPrice * it.Quantity,
|
||||
"cost_price": newPrice,
|
||||
"cost_amount": newPrice * it.Quantity,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -233,8 +234,8 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
if err := tx.Model(&model.StockOutItem{}).
|
||||
Where("shop_id = ? AND product_id = ?", shopID, it.ProductID).
|
||||
Updates(map[string]interface{}{
|
||||
"unit_price": newPrice,
|
||||
"total_price": gorm.Expr("? * quantity", newPrice),
|
||||
"cost_price": newPrice,
|
||||
"cost_amount": gorm.Expr("? * quantity", newPrice),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -245,15 +246,31 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
tx.Model(&model.Product{}).Where("id = ? AND shop_id = ?", it.ProductID, shopID).
|
||||
Update("purchase_price", newPrice)
|
||||
}
|
||||
if it.ProductID != 0 {
|
||||
changedProducts = append(changedProducts, it.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 成本快照变了 → 受影响出库单的总利润联动重算
|
||||
if len(changedProducts) > 0 {
|
||||
var outOrderIDs []uint64
|
||||
if err := tx.Model(&model.StockOutItem{}).
|
||||
Where("shop_id = ? AND product_id IN ?", shopID, changedProducts).
|
||||
Distinct().Pluck("order_id", &outOrderIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recalcStockOutProfit(tx, shopID, outOrderIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if totalDiff == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 入库单总额按差额重算
|
||||
// 4. 入库单应付合计按差额重算
|
||||
if err := tx.Model(&order).
|
||||
Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil {
|
||||
Update("cost_total", gorm.Expr("cost_total + ?", totalDiff)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -268,8 +285,22 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
})
|
||||
}
|
||||
|
||||
// recalcStockOutProfit 按行利润口径整单重算总利润:
|
||||
// profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×quantity : 0)。
|
||||
// 确认售价 / 确认进价(成本回填)后调用,保证利润与两侧价格始终一致。
|
||||
func recalcStockOutProfit(tx *gorm.DB, shopID uint64, orderIDs []uint64) error {
|
||||
if len(orderIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return 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, orderIDs).Error
|
||||
}
|
||||
|
||||
// ConfirmStockOutSale 出库「先出后定价」:对 sale_price<=0 的明细写真实售价,
|
||||
// 重算应收(total_amount=Σ售价×数量)并补应收差额流水。仅 admin/superadmin、已审核单可确认。
|
||||
// 重算应收(sale_total=Σ售价小计)与总利润,并补应收差额流水。仅 admin/superadmin、已审核单可确认。
|
||||
func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role string, items []SaleConfirmItem) error {
|
||||
if role != "admin" && role != "superadmin" {
|
||||
return ErrForbidden
|
||||
@@ -299,7 +330,11 @@ func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role
|
||||
}
|
||||
diff := (newPrice - it.SalePrice) * it.Quantity
|
||||
totalDiff += diff
|
||||
if err := tx.Model(it).Update("sale_price", newPrice).Error; err != nil {
|
||||
// 售价与售价小计同步改,避免「单价×数量 ≠ 金额」的口径漂移
|
||||
if err := tx.Model(it).Updates(map[string]interface{}{
|
||||
"sale_price": newPrice,
|
||||
"sale_amount": newPrice * it.Quantity,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -308,9 +343,12 @@ func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role
|
||||
return nil
|
||||
}
|
||||
|
||||
// 应收总额按差额重算
|
||||
// 应收合计按差额重算;总利润整单重算
|
||||
if err := tx.Model(&order).
|
||||
Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil {
|
||||
Update("sale_total", gorm.Expr("sale_total + ?", totalDiff)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recalcStockOutProfit(tx, shopID, []uint64{order.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -400,13 +438,13 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
|
||||
// 自动创建应收账款财务记录
|
||||
{
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.SaleTotal
|
||||
oid := order.ID
|
||||
rec := model.FinanceRecord{
|
||||
ShopID: shopID,
|
||||
PartnerID: order.PartnerID,
|
||||
Type: "receivable",
|
||||
Amount: order.TotalAmount,
|
||||
Amount: order.SaleTotal,
|
||||
Balance: bal,
|
||||
Status: "open",
|
||||
RefType: "stock_out",
|
||||
@@ -497,7 +535,7 @@ func (s *StockService) ReturnStockIn(shopID, orderID, userID uint64, role string
|
||||
return err
|
||||
}
|
||||
it.ReturnedQuantity = it.Quantity
|
||||
returnedAmount += it.TotalPrice
|
||||
returnedAmount += it.CostAmount
|
||||
}
|
||||
|
||||
// 冲减应付(负向调整记录,滚动余额)
|
||||
@@ -549,8 +587,8 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
var unitPricePtr *float64
|
||||
if it.UnitPrice != 0 {
|
||||
up := it.UnitPrice
|
||||
if it.CostPrice != 0 {
|
||||
up := it.CostPrice
|
||||
unitPricePtr = &up
|
||||
}
|
||||
unit := ""
|
||||
@@ -584,7 +622,7 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin
|
||||
return err
|
||||
}
|
||||
it.ReturnedQuantity = it.Quantity
|
||||
// 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致)
|
||||
// 冲应收按售价口径(与审核建应收 SaleTotal=Σ售价×数量 一致)
|
||||
returnedAmount += it.Quantity * it.SalePrice
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 10,
|
||||
UnitPrice: 5.0,
|
||||
TotalPrice: 50.0,
|
||||
CostPrice: 5.0,
|
||||
CostAmount: 50.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -157,8 +157,8 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 5,
|
||||
UnitPrice: 10.0,
|
||||
TotalPrice: 50.0,
|
||||
CostPrice: 10.0,
|
||||
CostAmount: 50.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ func main() {
|
||||
// 回填存量往来单位的拼音搜索列(幂等,只处理空值行)
|
||||
backfillPartnerPinyin(db)
|
||||
|
||||
// 2026-07 定价字段消歧迁移:旧列(unit_price/total_price/total_amount)值拷入新列,
|
||||
// 幂等(新列为 0 才拷),旧列保留不再读写、观察一版后手动 DROP
|
||||
service.BackfillPricingColumns(db)
|
||||
|
||||
// 启动会话/失败登录保留期清理任务(后台 goroutine)
|
||||
service.StartSessionCleanup(db, config.C.Session.RetentionDays)
|
||||
|
||||
@@ -183,3 +187,4 @@ func backfillPartnerPinyin(db *gorm.DB) {
|
||||
log.Printf("backfill partner pinyin: %d rows", filled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ CREATE TABLE IF NOT EXISTS `stock_in_orders` (
|
||||
`reviewer_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '审核人',
|
||||
`status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft',
|
||||
`order_date` DATE NOT NULL,
|
||||
`total_amount` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
||||
`cost_total` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '应付合计=Σ总进价(2026-07 消歧,旧列 total_amount 弃用)',
|
||||
`reviewed_at` DATETIME DEFAULT NULL,
|
||||
`custom_fields` JSON DEFAULT NULL,
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
@@ -307,8 +307,8 @@ CREATE TABLE IF NOT EXISTS `stock_in_items` (
|
||||
`series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)',
|
||||
`spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)',
|
||||
`quantity` DECIMAL(12,3) NOT NULL COMMENT '数量',
|
||||
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '单价',
|
||||
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
||||
`cost_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '进价·单瓶(2026-07 消歧,旧列 unit_price 弃用)',
|
||||
`cost_amount` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '总进价=quantity×cost_price(旧列 total_price 弃用)',
|
||||
`batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号',
|
||||
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
|
||||
`expire_date` DATE DEFAULT NULL COMMENT '有效期',
|
||||
@@ -334,7 +334,8 @@ CREATE TABLE IF NOT EXISTS `stock_out_orders` (
|
||||
`reviewer_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft',
|
||||
`order_date` DATE NOT NULL,
|
||||
`total_amount` DECIMAL(16,2) NOT NULL DEFAULT 0,
|
||||
`sale_total` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '应收合计=Σ售价小计(2026-07 消歧,旧列 total_amount 弃用)',
|
||||
`profit_total` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '总利润=Σ(sale_price>0?(sale_price-cost_price)×qty:0),建单落库、确认售价/进价联动重算',
|
||||
`reviewed_at` DATETIME DEFAULT NULL,
|
||||
`custom_fields` JSON DEFAULT NULL,
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
@@ -362,9 +363,10 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` (
|
||||
`series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)',
|
||||
`spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)',
|
||||
`quantity` DECIMAL(12,3) NOT NULL,
|
||||
`unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价(入库成本快照)',
|
||||
`cost_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价·入库成本快照(旧列 unit_price 弃用)',
|
||||
`sale_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '售价(出库实际销售单价,应收按售价×数量)',
|
||||
`total_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本小计(unit_price×quantity)',
|
||||
`cost_amount` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本小计=quantity×cost_price(旧列 total_price 弃用)',
|
||||
`sale_amount` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '售价小计=quantity×sale_price(待定价=0)',
|
||||
`batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号',
|
||||
`production_date` DATE DEFAULT NULL COMMENT '生产日期',
|
||||
`custom_fields` JSON DEFAULT NULL,
|
||||
|
||||
@@ -327,7 +327,7 @@ func SetupTestDB() *gorm.DB {
|
||||
reviewer_id INTEGER,
|
||||
status TEXT DEFAULT 'draft',
|
||||
order_date DATETIME,
|
||||
total_amount REAL DEFAULT 0,
|
||||
cost_total REAL DEFAULT 0,
|
||||
reviewed_at DATETIME,
|
||||
return_state TEXT DEFAULT 'none',
|
||||
custom_fields TEXT,
|
||||
@@ -347,8 +347,8 @@ func SetupTestDB() *gorm.DB {
|
||||
series TEXT,
|
||||
spec TEXT,
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL DEFAULT 0,
|
||||
total_price REAL DEFAULT 0,
|
||||
cost_price REAL DEFAULT 0,
|
||||
cost_amount REAL DEFAULT 0,
|
||||
returned_quantity REAL DEFAULT 0,
|
||||
batch_no TEXT,
|
||||
production_date DATETIME,
|
||||
@@ -370,7 +370,8 @@ func SetupTestDB() *gorm.DB {
|
||||
reviewer_id INTEGER,
|
||||
status TEXT DEFAULT 'draft',
|
||||
order_date DATETIME,
|
||||
total_amount REAL DEFAULT 0,
|
||||
sale_total REAL DEFAULT 0,
|
||||
profit_total REAL DEFAULT 0,
|
||||
reviewed_at DATETIME,
|
||||
return_state TEXT DEFAULT 'none',
|
||||
custom_fields TEXT,
|
||||
@@ -390,9 +391,10 @@ func SetupTestDB() *gorm.DB {
|
||||
series TEXT,
|
||||
spec TEXT,
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL DEFAULT 0,
|
||||
cost_price REAL DEFAULT 0,
|
||||
sale_price REAL DEFAULT 0,
|
||||
total_price REAL DEFAULT 0,
|
||||
cost_amount REAL DEFAULT 0,
|
||||
sale_amount REAL DEFAULT 0,
|
||||
returned_quantity REAL DEFAULT 0,
|
||||
batch_no TEXT,
|
||||
production_date DATETIME,
|
||||
|
||||
@@ -1045,7 +1045,7 @@ Future<Uint8List> buildStockInOrderPdfImpl(
|
||||
final rows = <List<String>>[];
|
||||
for (final it in order.items) {
|
||||
totalQty += it.quantity;
|
||||
totalAmt += it.totalPrice;
|
||||
totalAmt += it.costAmount;
|
||||
rows.add([
|
||||
it.productCode ?? '',
|
||||
it.productName ?? '',
|
||||
@@ -1054,8 +1054,8 @@ Future<Uint8List> buildStockInOrderPdfImpl(
|
||||
it.batchNo ?? '',
|
||||
_d10(it.productionDate),
|
||||
_qtyStr(it.quantity),
|
||||
it.unitPrice.toStringAsFixed(2),
|
||||
it.totalPrice.toStringAsFixed(2),
|
||||
it.costPrice.toStringAsFixed(2),
|
||||
it.costAmount.toStringAsFixed(2),
|
||||
'',
|
||||
]);
|
||||
}
|
||||
@@ -1112,9 +1112,9 @@ Future<Uint8List> buildStockOutOrderPdfImpl(
|
||||
final rows = <List<String>>[];
|
||||
for (final it in order.items) {
|
||||
// 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、
|
||||
// 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。
|
||||
final unit = it.salePrice > 0 ? it.salePrice : it.unitPrice;
|
||||
final amt = it.salePrice > 0 ? it.salePrice * it.quantity : it.totalPrice;
|
||||
// 实际售价存在 cost_price/cost_amount(源列直存),故售价为空时回退成本列。
|
||||
final unit = it.salePrice > 0 ? it.salePrice : it.costPrice;
|
||||
final amt = it.salePrice > 0 ? it.saleAmount : it.costAmount;
|
||||
totalQty += it.quantity;
|
||||
totalAmt += amt;
|
||||
rows.add([
|
||||
|
||||
@@ -240,7 +240,7 @@ Future<void> printStockInOrderImpl(
|
||||
double totalAmt = 0;
|
||||
for (final item in order.items) {
|
||||
totalQty += item.quantity;
|
||||
totalAmt += item.totalPrice;
|
||||
totalAmt += item.costAmount;
|
||||
rows.write('''<tr>
|
||||
<td>${item.productCode ?? ''}</td>
|
||||
<td>${item.productName ?? ''}</td>
|
||||
@@ -249,8 +249,8 @@ Future<void> printStockInOrderImpl(
|
||||
<td class="c">${item.batchNo ?? ''}</td>
|
||||
<td class="c">${_d10(item.productionDate)}</td>
|
||||
<td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td>
|
||||
<td class="r">${item.unitPrice.toStringAsFixed(2)}</td>
|
||||
<td class="r">${item.totalPrice.toStringAsFixed(2)}</td>
|
||||
<td class="r">${item.costPrice.toStringAsFixed(2)}</td>
|
||||
<td class="r">${item.costAmount.toStringAsFixed(2)}</td>
|
||||
<td></td>
|
||||
</tr>''');
|
||||
}
|
||||
@@ -324,10 +324,9 @@ Future<void> printStockOutOrderImpl(
|
||||
double totalAmt = 0;
|
||||
for (final item in order.items) {
|
||||
// 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、
|
||||
// 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。
|
||||
final unit = item.salePrice > 0 ? item.salePrice : item.unitPrice;
|
||||
final amt =
|
||||
item.salePrice > 0 ? item.salePrice * item.quantity : item.totalPrice;
|
||||
// 实际售价存在 cost_price/cost_amount(源列直存),故售价为空时回退成本列。
|
||||
final unit = item.salePrice > 0 ? item.salePrice : item.costPrice;
|
||||
final amt = item.salePrice > 0 ? item.saleAmount : item.costAmount;
|
||||
totalQty += item.quantity;
|
||||
totalAmt += amt;
|
||||
rows.write('''<tr>
|
||||
|
||||
@@ -3,8 +3,8 @@ class StockInItem {
|
||||
final int? orderId;
|
||||
final int productId;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
final double totalPrice;
|
||||
final double costPrice; // 进价(单瓶)
|
||||
final double costAmount; // 总进价 = quantity × costPrice
|
||||
final double returnedQuantity; // 0=未退;>=quantity 表示整行已退单
|
||||
final String? batchNo;
|
||||
final String? productionDate;
|
||||
@@ -26,8 +26,8 @@ class StockInItem {
|
||||
this.orderId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.totalPrice,
|
||||
required this.costPrice,
|
||||
required this.costAmount,
|
||||
this.returnedQuantity = 0,
|
||||
this.batchNo,
|
||||
this.productionDate,
|
||||
@@ -57,8 +57,8 @@ class StockInItem {
|
||||
json['order_id'] != null ? (json['order_id'] as num).toInt() : null,
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||
totalPrice: (json['total_price'] as num).toDouble(),
|
||||
costPrice: (json['cost_price'] as num?)?.toDouble() ?? 0,
|
||||
costAmount: (json['cost_amount'] as num?)?.toDouble() ?? 0,
|
||||
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
|
||||
batchNo: json['batch_no'] as String?,
|
||||
productionDate: json['production_date'] as String?,
|
||||
@@ -81,8 +81,8 @@ class StockInItem {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'product_id': productId,
|
||||
'quantity': quantity,
|
||||
'unit_price': unitPrice,
|
||||
'total_price': totalPrice,
|
||||
'cost_price': costPrice,
|
||||
'cost_amount': costAmount,
|
||||
if (batchNo != null) 'batch_no': batchNo,
|
||||
if (productionDate != null) 'production_date': productionDate,
|
||||
};
|
||||
@@ -104,7 +104,7 @@ class StockInOrder {
|
||||
final String returnState; // none | partial | full(退单状态)
|
||||
final String? orderDate;
|
||||
final String? reviewedAt;
|
||||
final double? totalAmount;
|
||||
final double? costTotal; // 应付合计 = Σ 总进价
|
||||
final String? remark;
|
||||
final List<StockInItem> items;
|
||||
|
||||
@@ -124,7 +124,7 @@ class StockInOrder {
|
||||
this.returnState = 'none',
|
||||
this.orderDate,
|
||||
this.reviewedAt,
|
||||
this.totalAmount,
|
||||
this.costTotal,
|
||||
this.remark,
|
||||
this.items = const [],
|
||||
});
|
||||
@@ -155,8 +155,8 @@ class StockInOrder {
|
||||
returnState: json['return_state'] as String? ?? 'none',
|
||||
orderDate: json['order_date'] as String?,
|
||||
reviewedAt: json['reviewed_at'] as String?,
|
||||
totalAmount: json['total_amount'] != null
|
||||
? (json['total_amount'] as num).toDouble()
|
||||
costTotal: json['cost_total'] != null
|
||||
? (json['cost_total'] as num).toDouble()
|
||||
: null,
|
||||
remark: json['remark'] as String?,
|
||||
items: json['items'] != null
|
||||
|
||||
@@ -3,9 +3,10 @@ class StockOutItem {
|
||||
final int? orderId;
|
||||
final int productId;
|
||||
final double quantity;
|
||||
final double unitPrice;
|
||||
final double salePrice;
|
||||
final double totalPrice;
|
||||
final double costPrice; // 成本单价(入库成本快照,仅管理员可见——operator 响应被服务端抹零)
|
||||
final double salePrice; // 销售单价
|
||||
final double costAmount; // 成本小计 = quantity × costPrice
|
||||
final double saleAmount; // 售价小计 = quantity × salePrice(待定价=0)
|
||||
final double returnedQuantity;
|
||||
final String? productName;
|
||||
final String? productCode;
|
||||
@@ -22,9 +23,10 @@ class StockOutItem {
|
||||
this.orderId,
|
||||
required this.productId,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.costPrice = 0,
|
||||
this.salePrice = 0,
|
||||
required this.totalPrice,
|
||||
this.costAmount = 0,
|
||||
this.saleAmount = 0,
|
||||
this.returnedQuantity = 0,
|
||||
this.productName,
|
||||
this.productCode,
|
||||
@@ -51,9 +53,10 @@ class StockOutItem {
|
||||
json['order_id'] != null ? (json['order_id'] as num).toInt() : null,
|
||||
productId: (json['product_id'] as num).toInt(),
|
||||
quantity: (json['quantity'] as num).toDouble(),
|
||||
unitPrice: (json['unit_price'] as num).toDouble(),
|
||||
costPrice: (json['cost_price'] as num?)?.toDouble() ?? 0,
|
||||
salePrice: (json['sale_price'] as num?)?.toDouble() ?? 0,
|
||||
totalPrice: (json['total_price'] as num).toDouble(),
|
||||
costAmount: (json['cost_amount'] as num?)?.toDouble() ?? 0,
|
||||
saleAmount: (json['sale_amount'] as num?)?.toDouble() ?? 0,
|
||||
returnedQuantity: (json['returned_quantity'] as num?)?.toDouble() ?? 0,
|
||||
productName: lineOrProduct('product_name', 'name'),
|
||||
productCode: lineOrProduct('product_code', 'code'),
|
||||
@@ -85,7 +88,8 @@ class StockOutOrder {
|
||||
final String? orderDate;
|
||||
final String? reviewedAt;
|
||||
final String? createdAt;
|
||||
final double? totalAmount;
|
||||
final double? saleTotal; // 应收合计 = Σ 售价小计
|
||||
final double profitTotal; // 总利润(仅管理员可见,operator 被抹零)
|
||||
final String? remark;
|
||||
final List<StockOutItem> items;
|
||||
|
||||
@@ -106,7 +110,8 @@ class StockOutOrder {
|
||||
this.orderDate,
|
||||
this.reviewedAt,
|
||||
this.createdAt,
|
||||
this.totalAmount,
|
||||
this.saleTotal,
|
||||
this.profitTotal = 0,
|
||||
this.remark,
|
||||
this.items = const [],
|
||||
});
|
||||
@@ -138,9 +143,10 @@ class StockOutOrder {
|
||||
orderDate: json['order_date'] as String?,
|
||||
reviewedAt: json['reviewed_at'] as String?,
|
||||
createdAt: json['created_at'] as String?,
|
||||
totalAmount: json['total_amount'] != null
|
||||
? (json['total_amount'] as num).toDouble()
|
||||
saleTotal: json['sale_total'] != null
|
||||
? (json['sale_total'] as num).toDouble()
|
||||
: null,
|
||||
profitTotal: (json['profit_total'] as num?)?.toDouble() ?? 0,
|
||||
remark: json['remark'] as String?,
|
||||
items: json['items'] != null
|
||||
? (json['items'] as List)
|
||||
|
||||
@@ -32,6 +32,7 @@ class OrderFormShell extends StatelessWidget {
|
||||
final Widget detail;
|
||||
final String rowsLabel; // 明细 N 行
|
||||
final String totalText; // 已格式化 ¥
|
||||
final String? profitText; // 合计利润(出库·仅管理员;null 不渲染)
|
||||
final bool loading;
|
||||
|
||||
const OrderFormShell({
|
||||
@@ -45,6 +46,7 @@ class OrderFormShell extends StatelessWidget {
|
||||
required this.detail,
|
||||
required this.rowsLabel,
|
||||
required this.totalText,
|
||||
this.profitText,
|
||||
this.statusBadge,
|
||||
this.notice,
|
||||
this.loading = false,
|
||||
@@ -123,6 +125,20 @@ class OrderFormShell extends StatelessWidget {
|
||||
Text(rowsLabel,
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
const Spacer(),
|
||||
if (profitText != null) ...[
|
||||
Text('合计利润 ',
|
||||
style:
|
||||
TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||||
Text(profitText!,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsTitle,
|
||||
color:
|
||||
profitText!.contains('-') ? t.danger : t.success,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const SizedBox(width: 18),
|
||||
],
|
||||
Text('合计金额 ',
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||||
Text(totalText,
|
||||
|
||||
@@ -153,7 +153,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
final row = _ItemRow();
|
||||
row.productId = item.productId;
|
||||
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
|
||||
row.priceCtrl.text = item.unitPrice.toStringAsFixed(2);
|
||||
row.priceCtrl.text = item.costPrice.toStringAsFixed(2);
|
||||
row.selectedNameId =
|
||||
nameOpts.where((o) => o.name == item.productName).firstOrNull?.id;
|
||||
row.selectedSeriesId = seriesOpts
|
||||
@@ -375,9 +375,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
'series': optName(seriesOpts, it.selectedSeriesId),
|
||||
'spec': optName(specOpts, it.selectedSpecId),
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'cost_price': price,
|
||||
if (sale > 0) 'sale_price': sale,
|
||||
'total_price': qty * price,
|
||||
if (batchNo.isNotEmpty) 'batch_no': batchNo,
|
||||
if (productionDate.isNotEmpty) 'production_date': productionDate,
|
||||
};
|
||||
@@ -649,9 +648,9 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
GridCol('prodDate', '生产日期', width: 150, req: true),
|
||||
GridCol('batch', '批次号', width: 116, req: true),
|
||||
GridCol('qty', '数量', width: 82, num: true, req: true),
|
||||
GridCol('price', '进价', width: 100, num: true),
|
||||
GridCol('sale', '售价', width: 108, num: true, req: true),
|
||||
GridCol('amount', '金额', width: 110, num: true),
|
||||
GridCol('price', '进价(单瓶)', width: 108, num: true),
|
||||
GridCol('sale', '参考售价', width: 108, num: true, req: true),
|
||||
GridCol('amount', '总进价', width: 110, num: true),
|
||||
GridCol('act', '', width: 78),
|
||||
];
|
||||
|
||||
@@ -912,14 +911,14 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
num: true,
|
||||
hint: '0',
|
||||
onChanged: (_) => setState(() {}))),
|
||||
MobileCardField('进价(可留空/0)', null,
|
||||
MobileCardField('进价·单瓶(可留空/0)', null,
|
||||
valueWidget: GciField(
|
||||
controller: item.priceCtrl,
|
||||
num: true,
|
||||
money: true,
|
||||
hint: '留空=待定价',
|
||||
onChanged: (_) => setState(() {}))),
|
||||
MobileCardField('售价', null,
|
||||
MobileCardField('参考售价', null,
|
||||
valueWidget: GciField(
|
||||
controller: item.saleCtrl,
|
||||
num: true,
|
||||
@@ -927,7 +926,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
hint: '0.00',
|
||||
onChanged: (_) => setState(() {}))),
|
||||
MobileCardField(
|
||||
'金额', item.pending ? '待定价' : '¥${item.amount.toStringAsFixed(2)}'),
|
||||
'总进价', item.pending ? '待定价' : '¥${item.amount.toStringAsFixed(2)}'),
|
||||
MobileCardField('', null,
|
||||
valueWidget: TextButton.icon(
|
||||
onPressed: () => setState(() => item.expanded = !item.expanded),
|
||||
|
||||
@@ -688,7 +688,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
o.partnerName ?? '',
|
||||
o.status,
|
||||
o.orderDate,
|
||||
o.totalAmount,
|
||||
o.costTotal,
|
||||
])
|
||||
.toList(),
|
||||
);
|
||||
@@ -712,8 +712,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
return Text(o.warehouseName ?? '-');
|
||||
case 'amount':
|
||||
return Text(
|
||||
o.totalAmount != null
|
||||
? '¥${NumberFormat('#,##0').format(o.totalAmount)}'
|
||||
o.costTotal != null
|
||||
? '¥${NumberFormat('#,##0').format(o.costTotal)}'
|
||||
: '—',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
@@ -828,11 +828,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
fields: [
|
||||
MobileCardField('供应商', o.partnerName ?? '-'),
|
||||
MobileCardField('仓库', o.warehouseName ?? '-'),
|
||||
MobileCardField(
|
||||
'合计金额',
|
||||
o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-'),
|
||||
MobileCardField('合计金额',
|
||||
o.costTotal != null ? '¥${o.costTotal!.toStringAsFixed(2)}' : '-'),
|
||||
MobileCardField('入库员', o.operatorName ?? '-'),
|
||||
MobileCardField('审核员', o.reviewerName ?? '-'),
|
||||
],
|
||||
@@ -899,13 +896,13 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
series: it.productSeries ?? '—',
|
||||
spec: it.productSpec ?? '',
|
||||
qty: _fmtQty(it.quantity),
|
||||
price: it.unitPrice == 0 ? '待定价' : _money.format(it.unitPrice),
|
||||
price: it.costPrice == 0 ? '待定价' : _money.format(it.costPrice),
|
||||
amount:
|
||||
it.totalPrice == 0 ? '待定价' : _money.format(it.totalPrice),
|
||||
pending: it.unitPrice == 0,
|
||||
it.costAmount == 0 ? '待定价' : _money.format(it.costAmount),
|
||||
pending: it.costPrice == 0,
|
||||
))
|
||||
.toList(),
|
||||
totalText: o.totalAmount != null ? _money.format(o.totalAmount) : '—',
|
||||
totalText: o.costTotal != null ? _money.format(o.costTotal) : '—',
|
||||
actionGroups: _detailActionGroups(o),
|
||||
);
|
||||
}
|
||||
@@ -971,7 +968,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
}
|
||||
if (audit.isNotEmpty) groups.add(DrawerActionGroup('单据审核', audit));
|
||||
|
||||
final pending = o.items.where((it) => it.unitPrice == 0).length;
|
||||
final pending = o.items.where((it) => it.costPrice == 0).length;
|
||||
if (o.status == 'approved' && isAdmin && pending > 0) {
|
||||
groups.add(DrawerActionGroup('成本 · $pending 项待定价', [
|
||||
b('确认进价', () {
|
||||
@@ -1025,7 +1022,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
/// 确认进价:为 0 价(待定)明细补填真实进价,调后端前向补偿后刷新列表。
|
||||
Future<void> _confirmCost(StockInOrder o) async {
|
||||
final pending =
|
||||
o.items.where((it) => it.unitPrice == 0 && it.id != null).toList();
|
||||
o.items.where((it) => it.costPrice == 0 && it.id != null).toList();
|
||||
if (pending.isEmpty) return;
|
||||
final controllers = {
|
||||
for (final it in pending) it.id!: TextEditingController()
|
||||
@@ -1303,8 +1300,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
series: it.productSeries ?? '',
|
||||
spec: it.productSpec ?? '',
|
||||
quantity: it.quantity,
|
||||
unitPrice: it.unitPrice,
|
||||
totalPrice: it.totalPrice,
|
||||
unitPrice: it.costPrice,
|
||||
totalPrice: it.costAmount,
|
||||
alreadyReturned: it.isReturned,
|
||||
))
|
||||
.toList();
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../core/auth/auth_state.dart';
|
||||
import '../../core/config/app_constants.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/utils/date_util.dart';
|
||||
import '../../models/inventory.dart';
|
||||
@@ -34,7 +35,7 @@ class _PickerItem {
|
||||
final String series;
|
||||
final String spec;
|
||||
final String unit;
|
||||
final double? unitPrice;
|
||||
final double? costPrice; // 进价(成本,仅管理员可见列)
|
||||
final double availableQty;
|
||||
const _PickerItem({
|
||||
required this.productId,
|
||||
@@ -43,7 +44,7 @@ class _PickerItem {
|
||||
required this.series,
|
||||
required this.spec,
|
||||
required this.unit,
|
||||
this.unitPrice,
|
||||
this.costPrice,
|
||||
required this.availableQty,
|
||||
});
|
||||
}
|
||||
@@ -62,7 +63,7 @@ List<_PickerItem> _aggregatePickerItems(List<Inventory> rows) {
|
||||
series: existing.series,
|
||||
spec: existing.spec,
|
||||
unit: existing.unit,
|
||||
unitPrice: existing.unitPrice ?? inv.unitPrice,
|
||||
costPrice: existing.costPrice ?? inv.unitPrice,
|
||||
availableQty: existing.availableQty + inv.quantity,
|
||||
);
|
||||
} else {
|
||||
@@ -73,7 +74,7 @@ List<_PickerItem> _aggregatePickerItems(List<Inventory> rows) {
|
||||
series: inv.series,
|
||||
spec: inv.spec,
|
||||
unit: inv.unit,
|
||||
unitPrice: inv.unitPrice,
|
||||
costPrice: inv.unitPrice,
|
||||
availableQty: inv.quantity,
|
||||
);
|
||||
}
|
||||
@@ -87,7 +88,7 @@ class _ItemRow {
|
||||
final String productName;
|
||||
final String series;
|
||||
final String spec;
|
||||
final double? unitPrice;
|
||||
final double? costPrice; // 进价(成本)
|
||||
final double? availableQty;
|
||||
final TextEditingController qtyCtrl;
|
||||
final TextEditingController salePriceCtrl;
|
||||
@@ -100,7 +101,7 @@ class _ItemRow {
|
||||
this.productName = '',
|
||||
this.series = '',
|
||||
this.spec = '',
|
||||
this.unitPrice,
|
||||
this.costPrice,
|
||||
this.availableQty,
|
||||
double? salePrice,
|
||||
}) : qtyCtrl = TextEditingController(text: '1'),
|
||||
@@ -108,7 +109,7 @@ class _ItemRow {
|
||||
salePriceCtrl = TextEditingController(
|
||||
text: ((salePrice != null && salePrice > 0)
|
||||
? salePrice
|
||||
: (unitPrice ?? 0))
|
||||
: (costPrice ?? 0))
|
||||
.toStringAsFixed(2));
|
||||
|
||||
FocusNode focusNode(String field) => field == 'sale' ? saleFocus : qtyFocus;
|
||||
@@ -118,6 +119,13 @@ class _ItemRow {
|
||||
(double.tryParse(qtyCtrl.text) ?? 0) *
|
||||
(double.tryParse(salePriceCtrl.text) ?? 0);
|
||||
|
||||
/// 行利润 =(售价 − 进价)× 数量;售价未填(≤0)视为待定不计
|
||||
double get profit {
|
||||
final sale = double.tryParse(salePriceCtrl.text) ?? 0;
|
||||
if (sale <= 0) return 0;
|
||||
return (sale - (costPrice ?? 0)) * (double.tryParse(qtyCtrl.text) ?? 0);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
qtyCtrl.dispose();
|
||||
salePriceCtrl.dispose();
|
||||
@@ -183,7 +191,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
productName: item.productName ?? '',
|
||||
series: item.productSeries ?? '',
|
||||
spec: item.productSpec ?? '',
|
||||
unitPrice: item.unitPrice,
|
||||
costPrice: item.costPrice,
|
||||
salePrice: item.salePrice,
|
||||
);
|
||||
row.qtyCtrl.text = item.quantity.toStringAsFixed(0);
|
||||
@@ -217,6 +225,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
return total;
|
||||
}
|
||||
|
||||
double get _totalProfit {
|
||||
double total = 0;
|
||||
for (final item in _items) {
|
||||
total += item.profit;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
Future<void> _loadInventory(int warehouseId) async {
|
||||
try {
|
||||
final result = await ref.read(inventoryRepositoryProvider).listInventory(
|
||||
@@ -254,7 +270,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
productName: item.productName,
|
||||
series: item.series,
|
||||
spec: item.spec,
|
||||
unitPrice: item.unitPrice,
|
||||
costPrice: item.costPrice,
|
||||
availableQty: item.availableQty,
|
||||
));
|
||||
}
|
||||
@@ -276,7 +292,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
productName: s.productName,
|
||||
series: s.series,
|
||||
spec: s.spec,
|
||||
unitPrice: s.unitPrice,
|
||||
costPrice: s.costPrice,
|
||||
availableQty: s.availableQty,
|
||||
);
|
||||
r.salePriceCtrl.text = s.salePriceCtrl.text;
|
||||
@@ -355,14 +371,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
|
||||
final itemsData = _items.map((item) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = item.unitPrice ?? 0;
|
||||
final price = item.costPrice ?? 0;
|
||||
final sale = double.tryParse(item.salePriceCtrl.text) ?? 0;
|
||||
// 小计/合计/利润由服务端按同口径重算落库,这里只传两侧单价
|
||||
return {
|
||||
'product_id': item.productId ?? 0,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'cost_price': price,
|
||||
'sale_price': sale,
|
||||
'total_price': qty * price,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
@@ -516,6 +532,9 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
mobileActions: mobileActions,
|
||||
rowsLabel: '明细 ${_items.length} 行',
|
||||
totalText: '¥${_totalAmount.toStringAsFixed(2)}',
|
||||
profitText: ref.watch(isAdminProvider)
|
||||
? '¥${_totalProfit.toStringAsFixed(2)}'
|
||||
: null,
|
||||
docHead: _buildDocHead(currentUser?.realName ?? '-'),
|
||||
detailHead: DetailHead(actions: [
|
||||
DsButton('从库存选择',
|
||||
@@ -628,18 +647,23 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
}
|
||||
|
||||
// ── 桌面:内联网格 ─────────────────────────────────────────────────────────
|
||||
static const _columns = [
|
||||
GridCol('idx', '#', width: 38),
|
||||
GridCol('name', '商品名称'),
|
||||
GridCol('series', '系列', width: 116),
|
||||
GridCol('spec', '规格', width: 128),
|
||||
GridCol('avail', '可用', width: 92, num: true),
|
||||
GridCol('qty', '数量', width: 82, num: true, req: true),
|
||||
GridCol('price', '进价', width: 100, num: true),
|
||||
GridCol('sale', '售价', width: 108, num: true, req: true),
|
||||
GridCol('amount', '金额', width: 110, num: true),
|
||||
GridCol('act', '', width: 62),
|
||||
];
|
||||
// 进价/利润列仅管理员可见(2026-07 定价重设计;operator 由服务端抹零+前端隐藏双保险);
|
||||
// 原「金额」列(=售价×数量,qty=1 时与售价重复)改为「利润」实时列。
|
||||
List<GridCol> get _columns {
|
||||
final admin = ref.read(isAdminProvider);
|
||||
return [
|
||||
const GridCol('idx', '#', width: 38),
|
||||
const GridCol('name', '商品名称'),
|
||||
const GridCol('series', '系列', width: 116),
|
||||
const GridCol('spec', '规格', width: 128),
|
||||
const GridCol('avail', '可用', width: 92, num: true),
|
||||
const GridCol('qty', '数量', width: 82, num: true, req: true),
|
||||
if (admin) const GridCol('price', '进价', width: 100, num: true),
|
||||
const GridCol('sale', '售价', width: 108, num: true, req: true),
|
||||
if (admin) const GridCol('profit', '利润', width: 110, num: true),
|
||||
const GridCol('act', '', width: 62),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildGrid() {
|
||||
if (_items.isEmpty) {
|
||||
@@ -687,7 +711,21 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
);
|
||||
case 'name':
|
||||
return RoCell(item.productName, color: t.heading);
|
||||
// 名称 + 编码两行;编码字体样式与系列列一致(RoCell 默认 muted)
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(item.productName,
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.heading)),
|
||||
if (item.productCode.isNotEmpty)
|
||||
Text(item.productCode,
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||||
],
|
||||
),
|
||||
);
|
||||
case 'series':
|
||||
return RoCell(item.series);
|
||||
case 'spec':
|
||||
@@ -711,8 +749,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
);
|
||||
case 'price':
|
||||
return RoCell(
|
||||
(item.unitPrice ?? 0) > 0
|
||||
? '¥${item.unitPrice!.toStringAsFixed(2)}'
|
||||
(item.costPrice ?? 0) > 0
|
||||
? '¥${item.costPrice!.toStringAsFixed(2)}'
|
||||
: '—',
|
||||
num: true,
|
||||
);
|
||||
@@ -727,8 +765,10 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
onEnter: () => _advance(i, 'sale'),
|
||||
onTab: ({required backward}) => _onTab(i, 'sale', backward: backward),
|
||||
);
|
||||
case 'amount':
|
||||
return AmountCell(amount: item.amount);
|
||||
case 'profit':
|
||||
return _ProfitCell(
|
||||
profit: item.profit,
|
||||
pending: (double.tryParse(item.salePriceCtrl.text) ?? 0) <= 0);
|
||||
case 'act':
|
||||
return RowActions(
|
||||
onCopy: () => _copyRow(i),
|
||||
@@ -772,11 +812,12 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
|
||||
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
|
||||
MobileCardField('可用', avail != null ? avail.toStringAsFixed(0) : '—'),
|
||||
MobileCardField(
|
||||
'进价',
|
||||
(item.unitPrice ?? 0) > 0
|
||||
? '¥${item.unitPrice!.toStringAsFixed(2)}'
|
||||
: '—'),
|
||||
if (ref.read(isAdminProvider))
|
||||
MobileCardField(
|
||||
'进价',
|
||||
(item.costPrice ?? 0) > 0
|
||||
? '¥${item.costPrice!.toStringAsFixed(2)}'
|
||||
: '—'),
|
||||
MobileCardField('数量', null,
|
||||
valueWidget: GciField(
|
||||
controller: item.qtyCtrl,
|
||||
@@ -790,12 +831,36 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
money: true,
|
||||
hint: '0.00',
|
||||
onChanged: (_) => setState(() {}))),
|
||||
MobileCardField('金额', '¥${item.amount.toStringAsFixed(2)}'),
|
||||
if (ref.read(isAdminProvider))
|
||||
MobileCardField('利润', '¥${item.profit.toStringAsFixed(2)}'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 利润单元格:正=success 负=danger 等宽粗体;售价未填显示 —
|
||||
class _ProfitCell extends StatelessWidget {
|
||||
final double profit;
|
||||
final bool pending;
|
||||
const _ProfitCell({required this.profit, required this.pending});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(pending ? '—' : '¥${profit.toStringAsFixed(2)}',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: pending ? t.faint : (profit < 0 ? t.danger : t.success),
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w600)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 意图(键盘快捷键)────────────────────────────────────────────────────────
|
||||
class _SubmitIntent extends Intent {
|
||||
const _SubmitIntent();
|
||||
@@ -1012,8 +1077,8 @@ class _InventoryPickerDialogState
|
||||
_dataCell(item.spec, 130,
|
||||
color: context.tokens.muted),
|
||||
_dataCell(
|
||||
item.unitPrice != null
|
||||
? '¥${item.unitPrice!.toStringAsFixed(2)}'
|
||||
item.costPrice != null
|
||||
? '¥${item.costPrice!.toStringAsFixed(2)}'
|
||||
: '-',
|
||||
90,
|
||||
),
|
||||
|
||||
@@ -364,8 +364,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
Text(o.partnerName ?? '-'),
|
||||
Text(o.warehouseName ?? '-'),
|
||||
Text(
|
||||
o.totalAmount != null
|
||||
? '¥${NumberFormat('#,##0').format(o.totalAmount)}'
|
||||
o.saleTotal != null
|
||||
? '¥${NumberFormat('#,##0').format(o.saleTotal)}'
|
||||
: '—',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
@@ -725,7 +725,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
o.partnerName ?? '',
|
||||
o.status,
|
||||
o.orderDate,
|
||||
o.totalAmount,
|
||||
o.saleTotal,
|
||||
])
|
||||
.toList(),
|
||||
);
|
||||
@@ -813,11 +813,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
fields: [
|
||||
MobileCardField('客户', o.partnerName ?? '-'),
|
||||
MobileCardField('仓库', o.warehouseName ?? '-'),
|
||||
MobileCardField(
|
||||
'金额',
|
||||
o.totalAmount != null
|
||||
? '¥${o.totalAmount!.toStringAsFixed(2)}'
|
||||
: '-'),
|
||||
MobileCardField('金额',
|
||||
o.saleTotal != null ? '¥${o.saleTotal!.toStringAsFixed(2)}' : '-'),
|
||||
MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'),
|
||||
],
|
||||
actions: _orderActions(context, o),
|
||||
@@ -876,20 +873,30 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
),
|
||||
],
|
||||
linesLabel: '出库明细',
|
||||
lines: o.items
|
||||
.map((it) => OrderLine(
|
||||
name: it.productName ?? '—',
|
||||
code: it.productCode ?? '—',
|
||||
series: it.productSeries ?? '—',
|
||||
spec: it.productSpec ?? '',
|
||||
qty: _fmtQty(it.quantity),
|
||||
price: it.salePrice <= 0 ? '待定价' : _money.format(it.salePrice),
|
||||
amount:
|
||||
it.totalPrice == 0 ? '待定价' : _money.format(it.totalPrice),
|
||||
pending: it.salePrice <= 0,
|
||||
))
|
||||
.toList(),
|
||||
totalText: o.totalAmount != null ? _money.format(o.totalAmount) : '—',
|
||||
// 管理员:成本价/售价/利润 三列 + 合计利润;operator:售价/小计
|
||||
//(成本与利润由服务端按角色抹零,前端同时不渲染对应列——双保险)
|
||||
lines: o.items.map((it) {
|
||||
final pending = it.salePrice <= 0;
|
||||
final admin = ref.read(isAdminProvider);
|
||||
final profit = (it.salePrice - it.costPrice) * it.quantity;
|
||||
return OrderLine(
|
||||
name: it.productName ?? '—',
|
||||
code: it.productCode ?? '—',
|
||||
series: it.productSeries ?? '—',
|
||||
spec: it.productSpec ?? '',
|
||||
qty: _fmtQty(it.quantity),
|
||||
price: pending ? '待定价' : _money.format(it.salePrice),
|
||||
amount: pending ? '待定价' : _money.format(it.saleAmount),
|
||||
cost: admin ? _money.format(it.costPrice) : null,
|
||||
profit: admin ? (pending ? '待定价' : _money.format(profit)) : null,
|
||||
pending: pending,
|
||||
);
|
||||
}).toList(),
|
||||
totalText: o.saleTotal != null ? _money.format(o.saleTotal) : '—',
|
||||
profitText:
|
||||
ref.read(isAdminProvider) ? _money.format(o.profitTotal) : null,
|
||||
priceLabel: '售价',
|
||||
amountLabel: '小计',
|
||||
actionGroups: _detailActionGroups(o),
|
||||
);
|
||||
}
|
||||
@@ -1298,8 +1305,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
series: it.productSeries ?? '',
|
||||
spec: it.productSpec ?? '',
|
||||
quantity: it.quantity,
|
||||
unitPrice: it.unitPrice,
|
||||
totalPrice: it.totalPrice,
|
||||
// 退单冲应收是售价口径:展示与之一致(待定价单回退成本)
|
||||
unitPrice: it.salePrice > 0 ? it.salePrice : it.costPrice,
|
||||
totalPrice: it.salePrice > 0 ? it.saleAmount : it.costAmount,
|
||||
alreadyReturned: it.isReturned,
|
||||
))
|
||||
.toList();
|
||||
|
||||
@@ -55,6 +55,10 @@ class OrderLine {
|
||||
final String qty;
|
||||
final String price; // 已算好文案(或「待定价」)
|
||||
final String amount;
|
||||
// 出库管理员 6 列形态(2026-07 定价重设计):成本价 + 利润;
|
||||
// 两者非空时明细表渲染 数量/成本价/售价(price)/利润 四数值列,amount 不用。
|
||||
final String? cost;
|
||||
final String? profit;
|
||||
final bool pending; // 单价<=0:单价/金额显示待定价样式
|
||||
const OrderLine({
|
||||
required this.name,
|
||||
@@ -63,7 +67,9 @@ class OrderLine {
|
||||
required this.spec,
|
||||
required this.qty,
|
||||
required this.price,
|
||||
required this.amount,
|
||||
this.amount = '',
|
||||
this.cost,
|
||||
this.profit,
|
||||
this.pending = false,
|
||||
});
|
||||
}
|
||||
@@ -83,6 +89,9 @@ class OrderDetailDrawer extends StatelessWidget {
|
||||
final String linesLabel; // 入库明细 / 出库明细
|
||||
final List<OrderLine> lines;
|
||||
final String totalText; // 合计金额(已格式化)
|
||||
final String? profitText; // 合计利润(仅出库·管理员传入;null 不渲染)
|
||||
final String priceLabel; // 单价列头(入库=进价(单瓶)/出库=售价)
|
||||
final String amountLabel; // 金额列头(入库=总进价/出库 operator=小计)
|
||||
final List<DrawerActionGroup> actionGroups;
|
||||
|
||||
const OrderDetailDrawer({
|
||||
@@ -92,6 +101,9 @@ class OrderDetailDrawer extends StatelessWidget {
|
||||
required this.linesLabel,
|
||||
required this.lines,
|
||||
required this.totalText,
|
||||
this.profitText,
|
||||
this.priceLabel = '单价',
|
||||
this.amountLabel = '金额',
|
||||
required this.actionGroups,
|
||||
});
|
||||
|
||||
@@ -138,7 +150,10 @@ class OrderDetailDrawer extends StatelessWidget {
|
||||
child: Text(linesLabel,
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
),
|
||||
_LinesTable(lines: lines),
|
||||
_LinesTable(
|
||||
lines: lines,
|
||||
priceLabel: priceLabel,
|
||||
amountLabel: amountLabel),
|
||||
// ── 合计(.ltotal)──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||
@@ -159,6 +174,28 @@ class OrderDetailDrawer extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (profitText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text('合计利润 ',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody, color: t.muted)),
|
||||
const SizedBox(width: 6),
|
||||
Text(profitText!,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsTitle,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: profitText!.startsWith('-')
|
||||
? t.danger
|
||||
: t.success)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ── 操作组(.dactions)──
|
||||
if (actionGroups.isNotEmpty) const SizedBox(height: 18),
|
||||
for (var i = 0; i < actionGroups.length; i++) ...[
|
||||
@@ -207,7 +244,15 @@ class _DrawerRow extends StatelessWidget {
|
||||
|
||||
class _LinesTable extends StatelessWidget {
|
||||
final List<OrderLine> lines;
|
||||
const _LinesTable({required this.lines});
|
||||
final String priceLabel;
|
||||
final String amountLabel;
|
||||
const _LinesTable(
|
||||
{required this.lines,
|
||||
required this.priceLabel,
|
||||
required this.amountLabel});
|
||||
|
||||
// 任一行带成本/利润 → 出库管理员 6 列形态
|
||||
bool get _withProfit => lines.any((l) => l.cost != null || l.profit != null);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -233,8 +278,17 @@ class _LinesTable extends StatelessWidget {
|
||||
style: _hStyle(t), overflow: TextOverflow.ellipsis),
|
||||
c2: Text('系列 / 规格', style: _hStyle(t)),
|
||||
c3: Text('数量', textAlign: TextAlign.right, style: _hStyle(t)),
|
||||
c4: Text('单价', textAlign: TextAlign.right, style: _hStyle(t)),
|
||||
c5: Text('金额', textAlign: TextAlign.right, style: _hStyle(t)),
|
||||
c4: _withProfit
|
||||
? Text('成本价', textAlign: TextAlign.right, style: _hStyle(t))
|
||||
: Text(priceLabel,
|
||||
textAlign: TextAlign.right, style: _hStyle(t)),
|
||||
c5: _withProfit
|
||||
? Text('售价', textAlign: TextAlign.right, style: _hStyle(t))
|
||||
: Text(amountLabel,
|
||||
textAlign: TextAlign.right, style: _hStyle(t)),
|
||||
c6: _withProfit
|
||||
? Text('利润', textAlign: TextAlign.right, style: _hStyle(t))
|
||||
: null,
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < lines.length; i++)
|
||||
@@ -281,9 +335,18 @@ class _LinesTable extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
c4: _priceCell(context, lines[i].price, lines[i].pending),
|
||||
c5: _priceCell(context, lines[i].amount, lines[i].pending,
|
||||
accent: true),
|
||||
c4: _withProfit
|
||||
? _priceCell(
|
||||
context, lines[i].cost ?? '—', lines[i].pending)
|
||||
: _priceCell(context, lines[i].price, lines[i].pending),
|
||||
c5: _withProfit
|
||||
? _priceCell(context, lines[i].price, lines[i].pending)
|
||||
: _priceCell(context, lines[i].amount, lines[i].pending,
|
||||
accent: true),
|
||||
c6: _withProfit
|
||||
? _profitCell(
|
||||
context, lines[i].profit ?? '—', lines[i].pending)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -314,28 +377,56 @@ class _LinesTable extends StatelessWidget {
|
||||
color: accent ? t.text : t.text));
|
||||
}
|
||||
|
||||
// 5 列布局(对齐原型 grid 1fr 124 40 64 78)。
|
||||
// 5 列布局(对齐原型 grid 1fr 124 40 64 78);c6 非空时为出库 6 列形态
|
||||
// (数量/成本价/售价/利润,数值列等宽收窄)。
|
||||
Widget _lineRow(BuildContext context,
|
||||
{required Widget c1,
|
||||
required Widget c2,
|
||||
required Widget c3,
|
||||
required Widget c4,
|
||||
required Widget c5}) {
|
||||
required Widget c5,
|
||||
Widget? c6}) {
|
||||
final six = c6 != null;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: c1),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 96, child: c2),
|
||||
SizedBox(width: six ? 84 : 96, child: c2),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 34, child: c3),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 60, child: c4),
|
||||
SizedBox(width: six ? 56 : 60, child: c4),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 72, child: c5),
|
||||
SizedBox(width: six ? 56 : 72, child: c5),
|
||||
if (six) ...[
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(width: 56, child: c6),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 利润列:负数染 danger,正常 success;待定价沿 warn 样式
|
||||
Widget _profitCell(BuildContext context, String text, bool pending) {
|
||||
final t = context.tokens;
|
||||
if (pending) {
|
||||
return Text(text,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsXs,
|
||||
color: t.warn,
|
||||
fontWeight: FontWeight.w600));
|
||||
}
|
||||
return Text(text,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: text.startsWith('-') ? t.danger : t.success));
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionGroup extends StatelessWidget {
|
||||
|
||||
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 130 KiB |
@@ -26,7 +26,7 @@ const _orders = [
|
||||
reviewerName: '张管理',
|
||||
status: 'approved',
|
||||
orderDate: '2026-06-20',
|
||||
totalAmount: 34320),
|
||||
costTotal: 34320),
|
||||
StockInOrder(
|
||||
id: 2,
|
||||
orderNo: 'RK-20260618-009',
|
||||
@@ -36,7 +36,7 @@ const _orders = [
|
||||
operatorName: '李销售',
|
||||
status: 'pending',
|
||||
orderDate: '2026-06-18',
|
||||
totalAmount: 6700),
|
||||
costTotal: 6700),
|
||||
StockInOrder(
|
||||
id: 3,
|
||||
orderNo: 'RK-20260615-003',
|
||||
@@ -46,7 +46,7 @@ const _orders = [
|
||||
operatorName: '王经理',
|
||||
status: 'draft',
|
||||
orderDate: '2026-06-15',
|
||||
totalAmount: 5000),
|
||||
costTotal: 5000),
|
||||
StockInOrder(
|
||||
id: 4,
|
||||
orderNo: 'RK-20260612-001',
|
||||
@@ -57,7 +57,7 @@ const _orders = [
|
||||
reviewerName: '张管理',
|
||||
status: 'rejected',
|
||||
orderDate: '2026-06-12',
|
||||
totalAmount: 3500),
|
||||
costTotal: 3500),
|
||||
];
|
||||
|
||||
class _FakeStockInNotifier extends StockInListNotifier {
|
||||
|
||||
@@ -26,7 +26,7 @@ const _orders = [
|
||||
reviewerName: '张管理',
|
||||
status: 'approved',
|
||||
orderDate: '2026-06-20',
|
||||
totalAmount: 12800),
|
||||
saleTotal: 12800),
|
||||
StockOutOrder(
|
||||
id: 2,
|
||||
orderNo: 'CK-20260618-022',
|
||||
@@ -36,7 +36,7 @@ const _orders = [
|
||||
operatorName: '张前台',
|
||||
status: 'pending',
|
||||
orderDate: '2026-06-18',
|
||||
totalAmount: 5400),
|
||||
saleTotal: 5400),
|
||||
StockOutOrder(
|
||||
id: 3,
|
||||
orderNo: 'CK-20260615-017',
|
||||
@@ -46,7 +46,7 @@ const _orders = [
|
||||
operatorName: '李销售',
|
||||
status: 'draft',
|
||||
orderDate: '2026-06-15',
|
||||
totalAmount: 3200),
|
||||
saleTotal: 3200),
|
||||
StockOutOrder(
|
||||
id: 4,
|
||||
orderNo: 'CK-20260612-009',
|
||||
@@ -57,7 +57,7 @@ const _orders = [
|
||||
reviewerName: '张管理',
|
||||
status: 'rejected',
|
||||
orderDate: '2026-06-12',
|
||||
totalAmount: 1960),
|
||||
saleTotal: 1960),
|
||||
];
|
||||
|
||||
class _FakeStockOutNotifier extends StockOutListNotifier {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/theme/themes.dart';
|
||||
import 'package:jiu_client/widgets/order_detail_drawer.dart';
|
||||
|
||||
/// 2026-07 定价重设计:出库详情按角色两种形态——
|
||||
/// 管理员(行带 cost/profit)= 成本价/售价/利润 三数值列 + 合计利润;
|
||||
/// operator(不带)= 售价/小计两列,无成本无利润。
|
||||
void main() {
|
||||
Widget host(OrderDetailDrawer drawer) => MaterialApp(
|
||||
theme: buildTheme('a'),
|
||||
home: Scaffold(body: drawer),
|
||||
);
|
||||
|
||||
const line6 = OrderLine(
|
||||
name: '汾酒青花20年',
|
||||
code: 'ZXZ027232',
|
||||
series: '普通/53度',
|
||||
spec: '500ml*6/件',
|
||||
qty: '2',
|
||||
price: '¥400.00',
|
||||
cost: '¥355.00',
|
||||
profit: '¥90.00',
|
||||
);
|
||||
|
||||
const line4 = OrderLine(
|
||||
name: '汾酒青花20年',
|
||||
code: 'ZXZ027232',
|
||||
series: '普通/53度',
|
||||
spec: '500ml*6/件',
|
||||
qty: '2',
|
||||
price: '¥400.00',
|
||||
amount: '¥800.00',
|
||||
);
|
||||
|
||||
testWidgets('管理员形态:成本价/售价/利润列 + 合计利润', (tester) async {
|
||||
await tester.pumpWidget(host(OrderDetailDrawer(
|
||||
title: 'CK001',
|
||||
infoRows: const [],
|
||||
linesLabel: '出库明细',
|
||||
lines: const [line6],
|
||||
totalText: '¥800.00',
|
||||
profitText: '¥90.00',
|
||||
priceLabel: '售价',
|
||||
amountLabel: '小计',
|
||||
actionGroups: const [],
|
||||
)));
|
||||
expect(find.text('成本价'), findsOneWidget);
|
||||
expect(find.text('售价'), findsOneWidget);
|
||||
expect(find.text('利润'), findsOneWidget);
|
||||
expect(find.text('¥355.00'), findsOneWidget);
|
||||
expect(find.text('¥90.00'), findsNWidgets(2)); // 行利润 + 合计利润
|
||||
expect(find.text('合计利润 '), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('operator 形态:售价/小计,无成本无利润', (tester) async {
|
||||
await tester.pumpWidget(host(OrderDetailDrawer(
|
||||
title: 'CK001',
|
||||
infoRows: const [],
|
||||
linesLabel: '出库明细',
|
||||
lines: const [line4],
|
||||
totalText: '¥800.00',
|
||||
priceLabel: '售价',
|
||||
amountLabel: '小计',
|
||||
actionGroups: const [],
|
||||
)));
|
||||
expect(find.text('售价'), findsOneWidget);
|
||||
expect(find.text('小计'), findsOneWidget);
|
||||
expect(find.text('成本价'), findsNothing);
|
||||
expect(find.text('利润'), findsNothing);
|
||||
expect(find.text('合计利润 '), findsNothing);
|
||||
expect(find.text('¥800.00'), findsNWidgets(2)); // 行小计 + 合计金额
|
||||
});
|
||||
}
|
||||
@@ -136,7 +136,7 @@ StockInOrder _makeOrder({
|
||||
warehouseName: warehouseName,
|
||||
partnerName: partnerName,
|
||||
status: status,
|
||||
totalAmount: 1000.0,
|
||||
costTotal: 1000.0,
|
||||
orderDate: '2024-01-10',
|
||||
);
|
||||
|
||||
|
||||
@@ -17,11 +17,10 @@ server {
|
||||
listen 443 ssl http2 default_server; # 正式入口(备案后回切 2026-07-03)
|
||||
server_name jiu.51yanmei.com _;
|
||||
|
||||
# 证书现为 EC2 同步副本(2026-08-28 到期)。ACME 通道已就绪:待批准后在 ali 跑
|
||||
# `certbot certonly --webroot -w /var/www/acme -d jiu.51yanmei.com` 签新证,
|
||||
# 然后把这两行换成 /etc/letsencrypt/live/jiu.51yanmei.com/ 并配 reload 部署钩子。
|
||||
ssl_certificate /etc/nginx/ssl/jiu.51yanmei.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/jiu.51yanmei.com/privkey.pem;
|
||||
# 证书:certbot webroot 自动续期(certbot-renew.timer 每日 + deploy 钩子 reload;
|
||||
# 2026-07-03 签发,authenticator=webroot,dry-run 验证通过)
|
||||
ssl_certificate /etc/letsencrypt/live/jiu.51yanmei.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/jiu.51yanmei.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
@@ -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 在上)+ 盒式输入(添加设备等)。
|
||||
|
||||
@@ -432,7 +432,7 @@
|
||||
<tr><td class="mono">reviewer_id</td><td class="mono ty">BIGINT UNSIGNED</td><td class="ctr">是</td><td class="mono df">NULL</td><td>审核人</td></tr>
|
||||
<tr><td class="mono">status</td><td class="mono ty">ENUM('draft','pending','approved','rejected')</td><td class="ctr">否</td><td class="mono df">draft</td><td></td></tr>
|
||||
<tr><td class="mono">order_date</td><td class="mono ty">DATE</td><td class="ctr">否</td><td class="mono df">—</td><td></td></tr>
|
||||
<tr><td class="mono">total_amount</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td></td></tr>
|
||||
<tr><td class="mono">cost_total</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>应付合计=Σ总进价(2026-07 消歧,旧列 total_amount 弃用待删)</td></tr>
|
||||
<tr><td class="mono">reviewed_at</td><td class="mono ty">DATETIME</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
<tr><td class="mono">custom_fields</td><td class="mono ty">JSON</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
<tr><td class="mono">remark</td><td class="mono ty">VARCHAR(500)</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
@@ -456,8 +456,8 @@
|
||||
<tr><td class="mono">series</td><td class="mono ty">VARCHAR(100)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>系列(历史导入快照)</td></tr>
|
||||
<tr><td class="mono">spec</td><td class="mono ty">VARCHAR(100)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>规格(历史导入快照)</td></tr>
|
||||
<tr><td class="mono">quantity</td><td class="mono ty">DECIMAL(12,3)</td><td class="ctr">否</td><td class="mono df">—</td><td>数量</td></tr>
|
||||
<tr><td class="mono">unit_price</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>单价</td></tr>
|
||||
<tr><td class="mono">total_price</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td></td></tr>
|
||||
<tr><td class="mono">cost_price</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>进价·单瓶(旧列 unit_price 弃用待删)</td></tr>
|
||||
<tr><td class="mono">cost_amount</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>总进价=quantity×cost_price(旧列 total_price 弃用待删)</td></tr>
|
||||
<tr><td class="mono">batch_no</td><td class="mono ty">VARCHAR(50)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>批次号</td></tr>
|
||||
<tr><td class="mono">production_date</td><td class="mono ty">DATE</td><td class="ctr">是</td><td class="mono df">NULL</td><td>生产日期</td></tr>
|
||||
<tr><td class="mono">expire_date</td><td class="mono ty">DATE</td><td class="ctr">是</td><td class="mono df">NULL</td><td>有效期</td></tr>
|
||||
@@ -482,7 +482,8 @@
|
||||
<tr><td class="mono">reviewer_id</td><td class="mono ty">BIGINT UNSIGNED</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
<tr><td class="mono">status</td><td class="mono ty">ENUM('draft','pending','approved','rejected')</td><td class="ctr">否</td><td class="mono df">draft</td><td></td></tr>
|
||||
<tr><td class="mono">order_date</td><td class="mono ty">DATE</td><td class="ctr">否</td><td class="mono df">—</td><td></td></tr>
|
||||
<tr><td class="mono">total_amount</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td></td></tr>
|
||||
<tr><td class="mono">sale_total</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>应收合计=Σ售价小计(旧列 total_amount 弃用待删)</td></tr>
|
||||
<tr><td class="mono">profit_total</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>总利润=Σ(售价>0?(售价-成本)×数量:0),建单落库、确认售价/进价联动重算;仅管理员可见(operator 响应抹零)</td></tr>
|
||||
<tr><td class="mono">reviewed_at</td><td class="mono ty">DATETIME</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
<tr><td class="mono">custom_fields</td><td class="mono ty">JSON</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
<tr><td class="mono">remark</td><td class="mono ty">VARCHAR(500)</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
@@ -494,7 +495,7 @@
|
||||
</div>
|
||||
<div class="card" id="stock_out_items">
|
||||
<h3><code class="tname">stock_out_items</code> <span class="tcomment">出库单明细</span></h3>
|
||||
<div class="callout danger"><b>当前问题相关:</b>出库行快照 <code>batch_no/production_date</code> 在 App 建单时常为空(选货按 product 聚合、未带批次)。打印回退到 <code>products</code> 主记录——若该 product 也没填,就显示空白,但库存页仍能显示(取自入库明细)。<code>unit_price</code>=成本、<code>sale_price</code>=售价。</div>
|
||||
<div class="callout danger"><b>当前问题相关:</b>出库行快照 <code>batch_no/production_date</code> 在 App 建单时常为空(选货按 product 聚合、未带批次)。打印回退到 <code>products</code> 主记录——若该 product 也没填,就显示空白,但库存页仍能显示(取自入库明细)。<code>cost_price</code>=成本、<code>sale_price</code>=售价(2026-07 已消歧改名)。</div>
|
||||
<table>
|
||||
<thead><tr><th style="width:23%">列名</th><th style="width:20%">类型</th><th style="width:7%">可空</th><th style="width:16%">默认</th><th>说明</th></tr></thead>
|
||||
<tbody><tr><td class="mono"><b>id</b> <span class="tag pk">PK</span></td><td class="mono ty">BIGINT UNSIGNED</td><td class="ctr">否</td><td class="mono df">AUTO_INC</td><td></td></tr>
|
||||
@@ -506,9 +507,10 @@
|
||||
<tr><td class="mono">series</td><td class="mono ty">VARCHAR(100)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>系列(历史导入快照)</td></tr>
|
||||
<tr><td class="mono">spec</td><td class="mono ty">VARCHAR(100)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>规格(历史导入快照)</td></tr>
|
||||
<tr><td class="mono">quantity</td><td class="mono ty">DECIMAL(12,3)</td><td class="ctr">否</td><td class="mono df">—</td><td></td></tr>
|
||||
<tr><td class="mono">unit_price</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>成本单价(入库成本快照)</td></tr>
|
||||
<tr><td class="mono">cost_price</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>成本单价·入库成本快照(旧列 unit_price 弃用待删;仅管理员可见)</td></tr>
|
||||
<tr><td class="mono">sale_price</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>售价(出库实际销售单价,应收按售价×数量)</td></tr>
|
||||
<tr><td class="mono">total_price</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>成本小计(unit_price×quantity)</td></tr>
|
||||
<tr><td class="mono">cost_amount</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>成本小计=quantity×cost_price(旧列弃用待删;仅管理员可见)</td></tr>
|
||||
<tr><td class="mono">sale_amount</td><td class="mono ty">DECIMAL(16,2)</td><td class="ctr">否</td><td class="mono df">0</td><td>售价小计=quantity×sale_price(待定价=0)</td></tr>
|
||||
<tr><td class="mono">batch_no</td><td class="mono ty">VARCHAR(50)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>批次号</td></tr>
|
||||
<tr><td class="mono">production_date</td><td class="mono ty">DATE</td><td class="ctr">是</td><td class="mono df">NULL</td><td>生产日期</td></tr>
|
||||
<tr><td class="mono">custom_fields</td><td class="mono ty">JSON</td><td class="ctr">是</td><td class="mono df">NULL</td><td></td></tr>
|
||||
|
||||