feat:净菜订单生成历史新增餐品/食材双维度全链路对账视图,食物成分新增价格变更历史弹窗
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
70cbe278c5
commit
ccc24d787c
@@ -451,6 +451,10 @@ const FOODS = [
|
||||
{type:'编辑',title:'调整净菜类型',time:'2026-03-15 10:42',user:'王秀兰',role:'营养师',dept:'营养管理部',summary:'新增「免淘米」净菜规格,出净率 95%。',changes:[{field:'净菜类型',old:'1 项(普洗组)',new:'2 项(普洗组/免淘米)'}]},
|
||||
{type:'价格调整',title:'更新基础定价',time:'2026-06-05 16:30',user:'张明',role:'采购员',dept:'采购部',summary:'根据 6 月市场行情调整粳米基础定价。',changes:[{field:'基础定价',old:'3.2 元/100g',new:'3.5 元/100g'}]},
|
||||
{type:'编辑',title:'修订色泽描述',time:'2026-07-21 11:05',user:'李建国',role:'营养师',dept:'营养管理部',summary:'色泽描述语义化,补充感官评级。',changes:[{field:'色泽描述',old:'乳白色,半透明',new:'乳白色,半透明,颗粒饱满'},{field:'感官评级',old:'—',new:'优'}]}
|
||||
],
|
||||
priceHistory:[
|
||||
{price:'3.5',time:'2026-06-05 16:30',method:'手工录入',prevPrice:'3.2',operator:'张明'},
|
||||
{price:'3.2',time:'2026-03-20 10:00',method:'食材验收价',prevPrice:'3.0',operator:'王芳'}
|
||||
]},
|
||||
{name:'猪里脊肉',code:'0201001',alias:'里脊',firstClass:'畜禽肉类',secondClass:'猪肉',icon:'🥩',edible:'100',waterRate:'71.4',dataSource:0,basePrice:'18.8',vegTypes:[
|
||||
{groupId:'g1',alias:'里脊肉丝',specAlias:'细丝',yieldRate:'92',edibleRate:'100',cutSpec:{shape:'丝',length:10,width:1,height:1,technique:'平刀'}},
|
||||
@@ -474,6 +478,11 @@ const FOODS = [
|
||||
{type:'编辑',title:'修正嘌呤数据',time:'2026-05-18 13:20',user:'王秀兰',role:'营养师',dept:'营养管理部',summary:'对齐《中国食物成分表》标准,修正嘌呤相关指标。',changes:[{field:'总嘌呤',old:'118.5 mg',new:'132.6 mg'},{field:'次黄嘌呤',old:'42.1 mg',new:'51.2 mg'}]},
|
||||
{type:'价格调整',title:'更新基础定价',time:'2026-07-09 17:00',user:'张明',role:'采购员',dept:'采购部',summary:'夏季猪肉行情稳定,微调定价。',changes:[{field:'基础定价',old:'19.2 元/100g',new:'18.8 元/100g'}]},
|
||||
{type:'编辑',title:'调整慎食人群说明',time:'2026-08-01 10:15',user:'李建国',role:'营养师',dept:'营养管理部',summary:'细化肾病人群慎食提示。',changes:[{field:'人群备注',old:'肾病患者慎食',new:'肾病人群需控制蛋白质摄入'}]}
|
||||
],
|
||||
priceHistory:[
|
||||
{price:'18.8',time:'2026-07-09 17:00',method:'手工录入',prevPrice:'19.2',operator:'张明'},
|
||||
{price:'19.2',time:'2026-05-30 15:20',method:'食材验收价',prevPrice:'18.8',operator:'王芳'},
|
||||
{price:'18.8',time:'2026-04-02 09:10',method:'手工录入',prevPrice:'16.5',operator:'张明'}
|
||||
]},
|
||||
{name:'菠菜',code:'0701001',alias:'菠菜',firstClass:'蔬菜类',secondClass:'叶菜类',icon:'🥬',edible:'89',waterRate:'91.2',dataSource:0,basePrice:'4.2',vegTypes:[],
|
||||
nutri:{calorie:24,protein:2.6,fat:0.3,carbohydrate:3.1,dietary_fiber:1.7,
|
||||
@@ -490,6 +499,10 @@ const FOODS = [
|
||||
{type:'编辑',title:'完善叶酸数据',time:'2026-03-02 14:30',user:'李建国',role:'营养师',dept:'营养管理部',summary:'对齐最新检测数据,叶酸含量上调。',changes:[{field:'叶酸',old:'87 μg',new:'110 μg'}]},
|
||||
{type:'价格调整',title:'更新基础定价',time:'2026-05-20 11:00',user:'张明',role:'采购员',dept:'采购部',summary:'夏季本地菠菜集中上市,定价下调。',changes:[{field:'基础定价',old:'5.5 元/100g',new:'4.2 元/100g'}]},
|
||||
{type:'编辑',title:'调整慎食提示',time:'2026-06-12 09:40',user:'王秀兰',role:'营养师',dept:'营养管理部',summary:'明确草酸含量高的人群风险提示。',changes:[{field:'人群备注',old:'—',new:'肾结石人群慎食,草酸含量高'}]}
|
||||
],
|
||||
priceHistory:[
|
||||
{price:'4.2',time:'2026-05-20 11:00',method:'食材验收价',prevPrice:'5.5',operator:'张明'},
|
||||
{price:'5.5',time:'2026-03-15 09:40',method:'手工录入',prevPrice:'5.0',operator:'王芳'}
|
||||
]},
|
||||
{name:'鸡蛋',code:'0401001',alias:'鸡蛋',firstClass:'蛋类',secondClass:'鸡蛋',icon:'🥚',edible:'88',waterRate:'74.1',dataSource:0,basePrice:'6.5',vegTypes:[],
|
||||
nutri:{calorie:144,protein:13.3,fat:8.8,carbohydrate:2.8,dietary_fiber:0,
|
||||
@@ -506,6 +519,10 @@ const FOODS = [
|
||||
{type:'编辑',title:'完善生物素与胆碱',time:'2026-02-25 16:00',user:'李建国',role:'营养师',dept:'营养管理部',summary:'补充生物素、总胆碱等 4 项指标。',changes:[{field:'生物素',old:'—',new:'20 μg'},{field:'总胆碱',old:'—',new:'294 mg'}]},
|
||||
{type:'价格调整',title:'更新基础定价',time:'2026-04-18 10:20',user:'张明',role:'采购员',dept:'采购部',summary:'蛋价季节性波动,微调。',changes:[{field:'基础定价',old:'7.0 元/100g',new:'6.5 元/100g'}]},
|
||||
{type:'编辑',title:'补充碘含量',time:'2026-07-04 14:50',user:'王秀兰',role:'营养师',dept:'营养管理部',summary:'补充碘元素数据,完善矿物质维度。',changes:[{field:'碘',old:'—',new:'27.2 μg'}]}
|
||||
],
|
||||
priceHistory:[
|
||||
{price:'6.5',time:'2026-04-18 10:20',method:'手工录入',prevPrice:'7.0',operator:'张明'},
|
||||
{price:'7.0',time:'2026-02-20 08:50',method:'食材验收价',prevPrice:'6.8',operator:'王芳'}
|
||||
]},
|
||||
{name:'豆腐(北)',code:'0601001',alias:'北豆腐',firstClass:'豆及豆制品',secondClass:'豆制品',icon:'🧈',edible:'100',waterRate:'82.8',dataSource:1,basePrice:'5.0',vegTypes:[
|
||||
{groupId:'g1',alias:'块类组',specAlias:'大方块',yieldRate:'96',edibleRate:'100',cutSpec:{shape:'块',length:30,width:30,height:20,technique:'直刀'}},
|
||||
@@ -526,6 +543,10 @@ const FOODS = [
|
||||
{type:'价格调整',title:'更新基础定价',time:'2026-05-12 09:30',user:'张明',role:'采购员',dept:'采购部',summary:'豆制品批发价上调。',changes:[{field:'基础定价',old:'4.5 元/100g',new:'5.0 元/100g'}]},
|
||||
{type:'编辑',title:'补充痛风人群提示',time:'2026-06-28 11:45',user:'李建国',role:'营养师',dept:'营养管理部',summary:'新增痛风急性期适量提示。',changes:[{field:'人群备注',old:'—',new:'痛风急性期适量,嘌呤含量偏高'}]},
|
||||
{type:'编辑',title:'修订矿物质数据',time:'2026-08-05 14:00',user:'陈晓燕',role:'营养师',dept:'营养管理部',summary:'对齐《中国食物成分表》修正钙、镁数据。',changes:[{field:'钙',old:'105 mg',new:'138 mg'},{field:'镁',old:'52 mg',new:'63 mg'}]}
|
||||
],
|
||||
priceHistory:[
|
||||
{price:'5.0',time:'2026-05-12 09:30',method:'食材验收价',prevPrice:'4.5',operator:'张明'},
|
||||
{price:'4.5',time:'2026-03-01 14:00',method:'手工录入',prevPrice:'4.2',operator:'王芳'}
|
||||
]}
|
||||
];
|
||||
|
||||
@@ -579,7 +600,8 @@ function renderBasicInfo(f){
|
||||
html += `<div class="profile-field"><div class="profile-field__label">可食部分</div><div class="profile-field__value">${f.edible}%</div></div>`;
|
||||
html += `<div class="profile-field"><div class="profile-field__label">水分</div><div class="profile-field__value">${f.waterRate}%</div></div>`;
|
||||
html += `<div class="profile-field"><div class="profile-field__label">数据来源</div><div class="profile-field__value">${sourceLabel}</div></div>`;
|
||||
const priceHint = f.basePrice ? `<span style="font-size:var(--font-xs);color:var(--text-placeholder);margin-left:8px;font-weight:400;">(2026-06-05 手工录入)</span>` : '';
|
||||
const ph0 = (f.priceHistory && f.priceHistory.length) ? f.priceHistory[0] : null;
|
||||
const priceHint = (f.basePrice && ph0) ? `<span style="font-size:var(--font-xs);color:var(--primary);margin-left:8px;font-weight:400;cursor:pointer;" onclick="openPriceHistoryDialog()">(${ph0.time.slice(0,10)} ${ph0.method})</span>` : '';
|
||||
html += `<div class="profile-field"><div class="profile-field__label">基础定价</div><div class="profile-field__value">${f.basePrice ? f.basePrice + ' 元/100g' : '—'}${priceHint}</div></div>`;
|
||||
let vegTypesValue;
|
||||
if(f.vegTypes && f.vegTypes.length > 0){
|
||||
@@ -1033,6 +1055,34 @@ function confirmDeleteFood(){
|
||||
closeDeleteFoodDialog();
|
||||
alert('已删除该食材');
|
||||
}
|
||||
|
||||
/* ========== 价格变更历史弹窗 ========== */
|
||||
// 打开价格变更历史弹窗,回显当前选中食材的基础定价变更记录
|
||||
function openPriceHistoryDialog(){
|
||||
var f = FOODS[currentFood];
|
||||
var list = f.priceHistory || [];
|
||||
var body = document.getElementById('priceHistoryBody');
|
||||
if(list.length===0){
|
||||
body.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-placeholder);padding:40px;">暂无价格变更记录</td></tr>';
|
||||
} else {
|
||||
var rows = '';
|
||||
list.forEach(function(h){
|
||||
rows += '<tr>';
|
||||
rows += '<td>' + (h.price ? h.price + ' 元/100g' : '—') + '</td>';
|
||||
rows += '<td>' + (h.time || '—') + '</td>';
|
||||
rows += '<td>' + (h.method || '—') + '</td>';
|
||||
rows += '<td>' + (h.prevPrice ? h.prevPrice + ' 元/100g' : '—') + '</td>';
|
||||
rows += '<td>' + (h.operator || '—') + '</td>';
|
||||
rows += '</tr>';
|
||||
});
|
||||
body.innerHTML = rows;
|
||||
}
|
||||
document.getElementById('priceHistoryDialog').classList.add('open');
|
||||
}
|
||||
// 关闭价格变更历史弹窗
|
||||
function closePriceHistoryDialog(){
|
||||
document.getElementById('priceHistoryDialog').classList.remove('open');
|
||||
}
|
||||
</script>
|
||||
<!-- 食材分类管理弹窗 -->
|
||||
<div class="dialog-mask" id="categoryDialog">
|
||||
@@ -1103,5 +1153,23 @@ function confirmDeleteFood(){
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 价格变更历史弹窗 -->
|
||||
<div class="dialog-mask" id="priceHistoryDialog">
|
||||
<div class="dialog" style="width:640px;">
|
||||
<div class="dialog__header">
|
||||
<div class="dialog__title">价格变更历史</div>
|
||||
<button class="dialog__close" onclick="closePriceHistoryDialog()">✕</button>
|
||||
</div>
|
||||
<div class="dialog__body">
|
||||
<table class="cat-table">
|
||||
<thead><tr><th>价格</th><th>更新时间</th><th>更新方式</th><th>变更前价格</th><th>操作人</th></tr></thead>
|
||||
<tbody id="priceHistoryBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dialog__footer">
|
||||
<button class="btn btn-default" onclick="closePriceHistoryDialog()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# 营养用餐管理端 CHANGELOG
|
||||
|
||||
## [2026-09-21] feat: 净菜订单生成历史双维度全链路对账 + 食物成分价格变更历史弹窗
|
||||
|
||||
- `prod-clean-order-history.html` 净菜订单生成历史「数据明细」弹窗重构为餐品/食材双维度对账视图:
|
||||
- 新增维度切换 Tab(🍽️ 餐品维度 / 🥬 食材维度),弹窗加宽至 1240px 并支持横向滚动
|
||||
- 餐品维度 12 列双层分组表头:生重环节(推荐生重/下单重量/组配生重)、熟重环节(推荐熟重/烹制熟重)、取餐环节(预测人数/实际人数/取餐重量),三组分色,每个实际值下方带差异率徽标
|
||||
- 食材维度 9 列聚合表:同一食材跨餐品合并一行,采供环节(采购数量/实收数量)、加工环节(出净率显式列/净菜产出量)、组配环节(组配实际用量),毛/净口径经出净率换算桥接
|
||||
- 食材行可展开查看被哪些餐品使用、各餐品用量贡献与占比;餐品行展开子表列名同步更名
|
||||
- 新增 ING_YIELD 出净率表与 COOK_YIELD 熟成率常量,各环节数值由确定性伪随机派生;生成中订单下游环节统一「—」占位
|
||||
- 表头文案不含「AI」字样,统一用「推荐生重/推荐熟重/推荐用量」,一级分组表头不带口径后缀
|
||||
- `din-ingredient.html` 食物成分:
|
||||
- 基础定价旁的定价来源提示改为可点击入口,新增「价格变更历史」弹窗(价格/更新时间/更新方式/变更前价格/操作人)
|
||||
- FOODS 数据模型新增 priceHistory 价格变更记录数组
|
||||
|
||||
## [2026-09-14] feat: 餐品定价规格拆分为餐品规格与餐品净菜包规格
|
||||
|
||||
- `din-dish-add.html` 新增/编辑餐品:
|
||||
|
||||
@@ -76,12 +76,12 @@ tbody tr:hover{background:#F8FBFF;}
|
||||
/* 弹窗 */
|
||||
.modal-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:200;align-items:center;justify-content:center;}
|
||||
.modal-mask.show{display:flex;}
|
||||
.modal{background:#fff;border-radius:var(--radius-lg);width:1100px;max-width:92vw;max-height:88vh;display:flex;flex-direction:column;box-shadow:0 8px 24px rgba(0,0,0,0.15);}
|
||||
.modal{background:#fff;border-radius:var(--radius-lg);width:1240px;max-width:94vw;max-height:88vh;display:flex;flex-direction:column;box-shadow:0 8px 24px rgba(0,0,0,0.15);}
|
||||
.modal__header{display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;border-bottom:1px solid var(--border);}
|
||||
.modal__title{font-size:var(--font-lg);font-weight:600;}
|
||||
.modal__close{width:32px;height:32px;border:none;background:none;font-size:18px;cursor:pointer;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--text-secondary);}
|
||||
.modal__close:hover{background:#f5f5f5;color:var(--text-primary);}
|
||||
.modal__body{padding:20px 24px;overflow-y:auto;flex:1;}
|
||||
.modal__body{padding:20px 24px;overflow-y:auto;overflow-x:auto;flex:1;}
|
||||
.modal__footer{display:flex;justify-content:flex-end;gap:8px;padding:16px 24px;border-top:1px solid var(--border);}
|
||||
/* 弹窗概要 */
|
||||
.detail-summary{display:flex;flex-wrap:wrap;gap:20px 40px;padding:14px 16px;background:#F7F9FC;border-radius:var(--radius-sm);margin-bottom:16px;}
|
||||
@@ -110,6 +110,22 @@ tbody tr:hover{background:#F8FBFF;}
|
||||
.ing-type--main{background:var(--primary-light);color:var(--primary);}
|
||||
.ing-type--aux{background:#F6FFED;color:var(--success);}
|
||||
.ing-type--season{background:#FFF7E6;color:var(--warning);}
|
||||
/* 维度切换 Tab */
|
||||
.dim-tabs{display:flex;gap:8px;margin-bottom:14px;}
|
||||
.dim-tab{height:34px;padding:0 22px;border-radius:6px;border:1px solid var(--border);background:#fff;font-size:var(--font-sm);color:var(--text-secondary);cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:6px;}
|
||||
.dim-tab:hover{color:var(--primary);border-color:var(--primary);}
|
||||
.dim-tab.active{background:var(--primary);border-color:var(--primary);color:#fff;font-weight:500;}
|
||||
/* 环节分组表头(生重/熟重/取餐、采供/加工/组配 分色) */
|
||||
.dish-table thead th.th-group{font-weight:600;border-bottom:none;}
|
||||
.dish-table thead th.th-group--raw,.dish-table thead th.th-group--ing-purchase{background:#F0F7FF;color:#1D6FE8;text-align:center;}
|
||||
.dish-table thead th.th-group--cook,.dish-table thead th.th-group--ing-process{background:#FFF7E6;color:#D48806;text-align:center;}
|
||||
.dish-table thead th.th-group--pickup,.dish-table thead th.th-group--ing-combo{background:#F6FFED;color:#389E0D;text-align:center;}
|
||||
/* 预测值/实际值单元格 */
|
||||
.val{font-weight:600;}
|
||||
.val--base{color:var(--primary);}
|
||||
.val-empty{color:var(--text-placeholder);font-weight:400;}
|
||||
.val-diff{margin-top:3px;line-height:1;}
|
||||
.tag--mini{font-size:10px;padding:1px 6px;border-radius:8px;}
|
||||
/* 空态 */
|
||||
.empty-state{text-align:center;color:var(--text-placeholder);padding:60px 0;font-size:var(--font-sm);}
|
||||
.empty-state .icon{font-size:40px;display:block;margin-bottom:12px;}
|
||||
@@ -140,7 +156,7 @@ tbody tr:hover{background:#F8FBFF;}
|
||||
<div class="page-header">
|
||||
<div class="page-header__left">
|
||||
<span class="page-header__title">净菜订单生成历史</span>
|
||||
<span class="page-header__desc">自动生成的净菜订单记录,点击「数据明细」查看餐品与食材用量对比</span>
|
||||
<span class="page-header__desc">自动生成的净菜订单记录,点击「数据明细」按餐品/食材双维度追溯下单、组配、烹制、取餐各环节准确性</span>
|
||||
</div>
|
||||
<div class="page-header__right">
|
||||
<a class="btn btn-default" href="prod-clean-order.html">← 返回净菜订单</a>
|
||||
@@ -196,10 +212,52 @@ tbody tr:hover{background:#F8FBFF;}
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<div class="detail-summary" id="detailSummary"></div>
|
||||
<table class="dish-table">
|
||||
<thead><tr><th style="width:28px;"></th><th>餐次</th><th>餐品名称</th><th>规格</th><th>就餐人数</th><th>打包份数</th><th>内配料</th><th>AI 推荐生重(kg)</th><th>AI 推荐熟重(kg)</th><th>实际制作量(kg)</th><th>差异率</th></tr></thead>
|
||||
<tbody id="detailDishBody"></tbody>
|
||||
</table>
|
||||
<div class="dim-tabs">
|
||||
<button class="dim-tab active" id="tabDish" onclick="switchDim('dish')">🍽️ 餐品维度</button>
|
||||
<button class="dim-tab" id="tabIng" onclick="switchDim('ing')">🥬 食材维度</button>
|
||||
</div>
|
||||
<div id="dimDishWrap">
|
||||
<table class="dish-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:28px;" rowspan="2"></th>
|
||||
<th rowspan="2">餐次</th>
|
||||
<th rowspan="2">餐品名称</th>
|
||||
<th rowspan="2">规格</th>
|
||||
<th colspan="3" class="th-group th-group--raw">生重环节</th>
|
||||
<th colspan="2" class="th-group th-group--cook">熟重环节</th>
|
||||
<th colspan="3" class="th-group th-group--pickup">取餐环节</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>推荐生重(kg)</th><th>下单重量(kg)</th><th>组配生重(kg)</th>
|
||||
<th>推荐熟重(kg)</th><th>烹制熟重(kg)</th>
|
||||
<th>预测人数</th><th>实际人数</th><th>取餐重量(kg)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailDishBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="dimIngWrap" style="display:none;">
|
||||
<table class="dish-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:28px;" rowspan="2"></th>
|
||||
<th rowspan="2">食材名称</th>
|
||||
<th rowspan="2">类别</th>
|
||||
<th rowspan="2">推荐用量(kg·净)</th>
|
||||
<th colspan="2" class="th-group th-group--ing-purchase">采供环节</th>
|
||||
<th colspan="2" class="th-group th-group--ing-process">加工环节</th>
|
||||
<th class="th-group th-group--ing-combo">组配环节</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>采购数量(kg·毛)</th><th>实收数量(kg·毛)</th>
|
||||
<th>出净率</th><th>净菜产出量(kg·净)</th>
|
||||
<th>组配实际用量(kg·净)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailIngBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn btn-default" onclick="closeDetail()">关闭</button>
|
||||
@@ -240,6 +298,11 @@ var recipeBom={
|
||||
'酸奶':[{name:'酸奶',type:'主材',perServing:200}]
|
||||
};
|
||||
var ING_TYPE_CLS={'主材':'ing-type--main','辅材':'ing-type--aux','调料':'ing-type--season'};
|
||||
/* 食材出净率(毛菜→净菜换算桥梁,食材维度显式露出) */
|
||||
var ING_YIELD={'鸡胸肉':0.88,'花生米':0.98,'黄瓜':0.85,'干辣椒':1,'葱姜蒜':0.9,'鲈鱼':0.82,'姜片':0.9,'葱丝':0.9,'五花肉':0.88,'酱油':1,'冰糖':1,'八角':1,'嫩豆腐':0.95,'肉末':0.9,'豆瓣酱':1,'花椒':1,'番茄':0.92,'鸡蛋':0.98,'葱花':0.9,'西兰花':0.82,'胡萝卜':0.88,'大蒜':0.88,'小米':1,'清水':1,'大米':1,'黄豆':1,'面粉':1,'酵母':1,'猪里脊':0.9,'木耳':1,'青椒':0.88,'猪排骨':0.85,'白糖':1,'香醋':1,'紫菜':1,'冬瓜':0.85,'酸奶':1,'茶叶':1};
|
||||
/* AI 预测熟成率(生重→熟重) */
|
||||
var COOK_YIELD=0.85;
|
||||
function round2(n){return parseFloat(n.toFixed(2));}
|
||||
|
||||
/* ========== 净菜订单生成历史数据(订单维度,一个订单可含多个餐次) ========== */
|
||||
var genOrders=[
|
||||
@@ -341,14 +404,26 @@ function nameHash(s){
|
||||
for(var i=0;i<s.length;i++){h=(h*31+s.charCodeAt(i))%1000;}
|
||||
return h;
|
||||
}
|
||||
/* ========== 派生:为订单每个餐品补齐食材明细与推荐/实际数值 ========== */
|
||||
/* ========== 派生:为订单每个餐品补齐双维度全链路数据 ========== */
|
||||
/* 餐品维度:AI 预测双值(生重/熟重)+ 下单/组配/烹制/取餐各环节实际值 */
|
||||
function deriveDetail(order){
|
||||
var generating=order.genStatus==='generating';
|
||||
return order.details.map(function(d){
|
||||
var bom=recipeBom[d.foodName]||[];
|
||||
var baseRatio=d.actualRatio!=null?d.actualRatio:(order.actualRatio||1);
|
||||
var perDishGram=bom.reduce(function(s,i){return s+i.perServing;},0);
|
||||
var recRaw=parseFloat((perDishGram*d.servings/1000).toFixed(2));
|
||||
var recCooked=parseFloat((recRaw*0.85).toFixed(2));
|
||||
var recCooked=parseFloat((recRaw*COOK_YIELD).toFixed(2));
|
||||
/* 下游环节实际值:生成中订单环节未发生,置 null 由展示层占位 */
|
||||
var orderWeight=null,comboRaw=null,cookCooked=null,actualDiner=null,pickupWeight=null;
|
||||
if(!generating){
|
||||
var seed=nameHash(d.foodName+d.meal);
|
||||
orderWeight=round2(recRaw*(1+(seed%9-4)/100));
|
||||
comboRaw=round2(orderWeight*(1+((seed>>2)%9-4)/100));
|
||||
cookCooked=round2(comboRaw*(COOK_YIELD+((seed>>3)%7-3)/100));
|
||||
actualDiner=Math.round(d.dinerCount*(1+((seed>>4)%11-5)/100));
|
||||
pickupWeight=round2(cookCooked*(0.88+(seed>>5)%7/100));
|
||||
}
|
||||
var ingredients=bom.map(function(ing){
|
||||
var ingRec=parseFloat((ing.perServing*d.servings/1000).toFixed(3));
|
||||
var offset=(nameHash(ing.name)%13-6)/100;
|
||||
@@ -356,14 +431,44 @@ function deriveDetail(order){
|
||||
var ingAct=parseFloat((ingRec*ingRatio).toFixed(3));
|
||||
return {name:ing.name,type:ing.type,perServing:ing.perServing,rec:ingRec,act:ingAct};
|
||||
});
|
||||
var actRaw=parseFloat(ingredients.reduce(function(s,i){return s+i.act;},0).toFixed(2));
|
||||
return {
|
||||
meal:d.meal,foodName:d.foodName,spec:d.spec,dinerCount:d.dinerCount,servings:d.servings,needSeasoning:d.needSeasoning,
|
||||
recRaw:recRaw,recCooked:recCooked,actRaw:actRaw,ingredients:ingredients
|
||||
recRaw:recRaw,recCooked:recCooked,
|
||||
orderWeight:orderWeight,comboRaw:comboRaw,cookCooked:cookCooked,actualDiner:actualDiner,pickupWeight:pickupWeight,
|
||||
ingredients:ingredients
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* 食材维度:同一食材跨餐品合并一行,链路覆盖 AI 用量→采购/实收(毛)→净菜产出(净)→组配实际(净) */
|
||||
function deriveIngredients(order){
|
||||
var generating=order.genStatus==='generating';
|
||||
var map={};
|
||||
deriveDetail(order).forEach(function(d){
|
||||
d.ingredients.forEach(function(ing){
|
||||
if(!map[ing.name]){
|
||||
map[ing.name]={name:ing.name,type:ing.type,aiUsed:0,seed:nameHash(ing.name),
|
||||
yieldRate:ING_YIELD[ing.name]!=null?ING_YIELD[ing.name]:0.9,usedBy:[]};
|
||||
}
|
||||
map[ing.name].aiUsed=parseFloat((map[ing.name].aiUsed+ing.rec).toFixed(3));
|
||||
map[ing.name].usedBy.push({meal:d.meal,foodName:d.foodName,qty:ing.rec});
|
||||
});
|
||||
});
|
||||
return Object.keys(map).map(function(k){
|
||||
var m=map[k],ai=m.aiUsed;
|
||||
if(!generating){
|
||||
var safety=1+(m.seed%7)/100; /* 采购安全余量 0~6% */
|
||||
m.purchase=parseFloat((ai/m.yieldRate*safety).toFixed(3)); /* 采购(毛)=净用量按出净率反推 */
|
||||
m.received=parseFloat((m.purchase*(1-(m.seed%4)/100)).toFixed(3)); /* 实收(毛)含收货损耗 0~3% */
|
||||
m.netOut=parseFloat((m.received*m.yieldRate).toFixed(3)); /* 净菜产出(净)=实收×出净率 */
|
||||
m.comboUsed=parseFloat((ai*(1+(m.seed%13-6)/100)).toFixed(3)); /* 组配实际(净)±6% */
|
||||
}else{
|
||||
m.purchase=m.received=m.netOut=m.comboUsed=null;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== 差异率 ========== */
|
||||
function diffInfo(act,rec){
|
||||
if(!rec)return {cls:'flat',text:'— 持平'};
|
||||
@@ -503,37 +608,100 @@ function openDetail(orderNo){
|
||||
summary+='<div class="detail-summary__item"><span class="detail-summary__label">创建人</span><span class="detail-summary__value">'+o.creator+'</span></div>';
|
||||
summary+='<div class="detail-summary__item"><span class="detail-summary__label">菜品种类</span><span class="detail-summary__value">'+distinctDishCount(o)+'</span></div>';
|
||||
document.getElementById('detailSummary').innerHTML=summary;
|
||||
/* 餐品列表 */
|
||||
var details=deriveDetail(o);
|
||||
/* 双维度渲染并重置回餐品维度 Tab */
|
||||
renderDishRows(deriveDetail(o));
|
||||
renderIngRows(deriveIngredients(o));
|
||||
switchDim('dish');
|
||||
document.getElementById('modal-detail').classList.add('show');
|
||||
}
|
||||
function closeDetail(){document.getElementById('modal-detail').classList.remove('show');}
|
||||
|
||||
/* ========== 维度切换 ========== */
|
||||
function switchDim(dim){
|
||||
document.getElementById('tabDish').classList.toggle('active',dim==='dish');
|
||||
document.getElementById('tabIng').classList.toggle('active',dim==='ing');
|
||||
document.getElementById('dimDishWrap').style.display=dim==='dish'?'':'none';
|
||||
document.getElementById('dimIngWrap').style.display=dim==='ing'?'':'none';
|
||||
}
|
||||
|
||||
/* ========== 数值 + 差异徽标单元格(生成中环节未发生时占位 —) ========== */
|
||||
function stageCell(val,rec){
|
||||
if(val==null)return '<td><span class="val-empty">—</span></td>';
|
||||
var diff=diffInfo(val,rec);
|
||||
return '<td><div class="val">'+val.toFixed(2)+'</div><div class="val-diff"><span class="tag tag--mini '+diff.cls+'">'+diff.text+'</span></div></td>';
|
||||
}
|
||||
function dinerCell(actual,plan){
|
||||
if(actual==null)return '<td><span class="val-empty">—</span></td>';
|
||||
var diff=diffInfo(actual,plan);
|
||||
return '<td><div class="val">'+actual+'</div><div class="val-diff"><span class="tag tag--mini '+diff.cls+'">'+diff.text+'</span></div></td>';
|
||||
}
|
||||
function ingStageCell(val,rec){
|
||||
if(val==null||rec==null)return '<td><span class="val-empty">—</span></td>';
|
||||
var diff=diffInfo(val,rec);
|
||||
return '<td><div class="val">'+val.toFixed(3)+'</div><div class="val-diff"><span class="tag tag--mini '+diff.cls+'">'+diff.text+'</span></div></td>';
|
||||
}
|
||||
function plainCell(val){
|
||||
if(val==null)return '<td><span class="val-empty">—</span></td>';
|
||||
return '<td><div class="val">'+val.toFixed(3)+'</div></td>';
|
||||
}
|
||||
|
||||
/* ========== 餐品维度表:生重生、熟重熟分开对账 ========== */
|
||||
function renderDishRows(details){
|
||||
var html='';
|
||||
details.forEach(function(d){
|
||||
var seasonTag=d.needSeasoning?'<span class="tag tag-blue">✓ 是</span>':'<span class="tag tag-gray">✗ 否</span>';
|
||||
var diff=diffInfo(d.actRaw,d.recRaw);
|
||||
html+='<tr class="dish-row" onclick="toggleIng(this)">';
|
||||
html+='<td><span class="expand-arrow">▶</span></td>';
|
||||
html+='<td><span class="tag tag-blue">'+d.meal+'</span></td>';
|
||||
html+='<td style="font-weight:600;">'+d.foodName+'</td>';
|
||||
html+='<td>'+d.spec+'</td>';
|
||||
html+='<td>'+d.dinerCount+'</td>';
|
||||
html+='<td>'+d.servings+'</td>';
|
||||
html+='<td>'+seasonTag+'</td>';
|
||||
html+='<td><b>'+d.recRaw.toFixed(2)+'</b></td>';
|
||||
html+='<td>'+d.recCooked.toFixed(2)+'</td>';
|
||||
html+='<td><b>'+d.actRaw.toFixed(2)+'</b></td>';
|
||||
html+='<td><span class="tag '+diff.cls+'">'+diff.text+'</span></td>';
|
||||
/* 生重环节 */
|
||||
html+='<td><span class="val val--base">'+d.recRaw.toFixed(2)+'</span></td>';
|
||||
html+=stageCell(d.orderWeight,d.recRaw);
|
||||
html+=stageCell(d.comboRaw,d.recRaw);
|
||||
/* 熟重环节 */
|
||||
html+='<td><span class="val val--base">'+d.recCooked.toFixed(2)+'</span></td>';
|
||||
html+=stageCell(d.cookCooked,d.recCooked);
|
||||
/* 取餐环节 */
|
||||
html+='<td><span class="val">'+d.dinerCount+'</span></td>';
|
||||
html+=dinerCell(d.actualDiner,d.dinerCount);
|
||||
html+=stageCell(d.pickupWeight,d.recCooked);
|
||||
html+='</tr>';
|
||||
html+='<tr class="ingredient-row" style="display:none;">';
|
||||
html+='<td colspan="11">'+buildIngredientsDetail(d)+'</td>';
|
||||
html+='<td colspan="12">'+buildIngredientsDetail(d)+'</td>';
|
||||
html+='</tr>';
|
||||
});
|
||||
document.getElementById('detailDishBody').innerHTML=html;
|
||||
document.getElementById('modal-detail').classList.add('show');
|
||||
}
|
||||
function closeDetail(){document.getElementById('modal-detail').classList.remove('show');}
|
||||
|
||||
/* ========== 食材维度表:跨餐品合并一行,毛/净口径沿链路对账 ========== */
|
||||
function renderIngRows(ings){
|
||||
var html='';
|
||||
ings.forEach(function(m){
|
||||
html+='<tr class="dish-row" onclick="toggleIng(this)">';
|
||||
html+='<td><span class="expand-arrow">▶</span></td>';
|
||||
html+='<td style="font-weight:600;">'+m.name+'</td>';
|
||||
var cls=ING_TYPE_CLS[m.type]||'';
|
||||
html+='<td><span class="ing-type '+cls+'">'+m.type+'</span></td>';
|
||||
html+='<td><span class="val val--base">'+m.aiUsed.toFixed(3)+'</span></td>';
|
||||
/* 采供环节:采购(链路起点)+ 实收 vs 采购(收货损耗) */
|
||||
html+=plainCell(m.purchase);
|
||||
html+=ingStageCell(m.received,m.purchase);
|
||||
/* 加工环节:出净率 + 净菜产出 */
|
||||
html+='<td><span class="val">'+(m.received!=null?(m.yieldRate*100).toFixed(0)+'%':'—')+'</span></td>';
|
||||
html+=plainCell(m.netOut);
|
||||
/* 组配环节:实际用量 vs AI 生成用量(核心对账) */
|
||||
html+=ingStageCell(m.comboUsed,m.aiUsed);
|
||||
html+='</tr>';
|
||||
html+='<tr class="ingredient-row" style="display:none;">';
|
||||
html+='<td colspan="9">'+buildUsedByDetail(m)+'</td>';
|
||||
html+='</tr>';
|
||||
});
|
||||
document.getElementById('detailIngBody').innerHTML=html;
|
||||
}
|
||||
document.querySelector('.modal-mask').addEventListener('click',function(e){if(e.target===this)closeDetail();});
|
||||
|
||||
function buildIngredientsDetail(d){
|
||||
var html='<table class="ing-table"><thead><tr><th>食材名称</th><th>类别</th><th>每份用量(g)</th><th>AI 推荐总量(kg)</th><th>实际用量(kg)</th><th>差异率</th></tr></thead><tbody>';
|
||||
var html='<table class="ing-table"><thead><tr><th>食材名称</th><th>类别</th><th>每份用量(g)</th><th>推荐总量(kg)</th><th>组配实际用量(kg)</th><th>差异率</th></tr></thead><tbody>';
|
||||
d.ingredients.forEach(function(ing){
|
||||
var cls=ING_TYPE_CLS[ing.type]||'';
|
||||
var diff=diffInfo(ing.act,ing.rec);
|
||||
@@ -549,6 +717,20 @@ function buildIngredientsDetail(d){
|
||||
html+='</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
/* 食材维度展开:该食材被哪些餐品使用(AI 用量贡献与占比) */
|
||||
function buildUsedByDetail(m){
|
||||
var html='<table class="ing-table"><thead><tr><th>餐次</th><th>使用餐品</th><th>用量贡献(kg)</th><th>占比</th></tr></thead><tbody>';
|
||||
m.usedBy.forEach(function(u){
|
||||
html+='<tr>';
|
||||
html+='<td>'+u.meal+'</td>';
|
||||
html+='<td>'+u.foodName+'</td>';
|
||||
html+='<td>'+u.qty.toFixed(3)+'</td>';
|
||||
html+='<td>'+(u.qty/m.aiUsed*100).toFixed(1)+'%</td>';
|
||||
html+='</tr>';
|
||||
});
|
||||
html+='</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
function toggleIng(tr){
|
||||
var next=tr.nextElementSibling;
|
||||
var arrow=tr.querySelector('.expand-arrow');
|
||||
|
||||
Reference in New Issue
Block a user