From e751d53362f94252606accdbb52e6b63d26dba4e Mon Sep 17 00:00:00 2001
From: hjq <770690987@qq.com>
Date: Mon, 22 Jun 2026 14:34:23 +0800
Subject: [PATCH] =?UTF-8?q?Dockerfile=20=E9=83=A8=E7=BD=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
browser_login/analysis_service.py | 226 ++++++++++++++++++++++-----
update_html.py | 57 +++++++
web_ui/app.py | 8 +-
web_ui/templates/reconciliation.html | 81 ++++++----
4 files changed, 299 insertions(+), 73 deletions(-)
create mode 100644 update_html.py
diff --git a/browser_login/analysis_service.py b/browser_login/analysis_service.py
index 29238a6..aaaa455 100644
--- a/browser_login/analysis_service.py
+++ b/browser_login/analysis_service.py
@@ -146,31 +146,36 @@ class MaterialReconciliationService:
conn.close()
return [dict(r) for r in rows]
- def step3_bom_reconciliation(self, start_date=None, end_date=None):
+ def step3_bom_reconciliation(self, start_date=None, end_date=None, match_type='official'):
"""
第三步:BOM 校准物料,比对发料与BOM差异
- 返回按工单分组的差异列表。
+ 返回按工单分组的树形结构。
"""
conn = self.get_conn()
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
- where_actual = "WHERE inferred_production_order_no != '' AND inferred_production_order_no IS NOT NULL"
+ if match_type == 'official':
+ where_actual = "WHERE production_order_no != '' AND production_order_no IS NOT NULL"
+ sfc_field = "production_order_no"
+ else:
+ where_actual = "WHERE (production_order_no = '' OR production_order_no IS NULL) AND inferred_production_order_no != '' AND inferred_production_order_no IS NOT NULL"
+ sfc_field = "inferred_production_order_no"
+
params_actual = []
-
if start_date and end_date:
where_actual += " AND execution_time >= ? AND execution_time <= ?"
params_actual.extend([f"{start_date} 00:00:00", f"{end_date} 23:59:59"])
sql_actual = f"""
SELECT
- inferred_production_order_no as sfc,
+ {sfc_field} as sfc,
material_code,
material_name,
SUM(issue_number) as total_actual_issue
FROM issue_receipt_details
{where_actual}
- GROUP BY inferred_production_order_no, material_code
+ GROUP BY {sfc_field}, material_code
"""
where_bom = ""
@@ -182,6 +187,8 @@ class MaterialReconciliationService:
sql_bom = f"""
SELECT
work_orders_number as sfc,
+ product_code,
+ product_name,
material_code,
material_name,
theoretical_issue_qty as bom_qty
@@ -195,45 +202,186 @@ class MaterialReconciliationService:
cursor.execute(sql_bom, params_bom)
boms = cursor.fetchall()
- conn.close()
-
- reconciliation = {}
-
+ sfc_to_product = {}
+ sfc_bom_dict = {} # {sfc: {material_code: {qty, name}}}
for r in boms:
- key = (r['sfc'], r['material_code'])
- reconciliation[key] = {
- 'sfc': r['sfc'],
- 'material_code': r['material_code'],
- 'material_name': r['material_name'],
+ sfc = r['sfc']
+ mat_code = r['material_code']
+ if sfc not in sfc_bom_dict:
+ sfc_bom_dict[sfc] = {}
+ sfc_bom_dict[sfc][mat_code] = {
'bom_qty': r['bom_qty'] or 0,
- 'actual_qty': 0,
- 'diff_qty': 0 - (r['bom_qty'] or 0),
- 'status': '未发料'
+ 'material_name': r['material_name']
+ }
+ if r['product_code']:
+ sfc_to_product[sfc] = {
+ 'product_code': r['product_code'],
+ 'product_name': r['product_name']
+ }
+
+ sfc_actual_dict = {} # {sfc: {material_code: {qty, name}}}
+ for r in actuals:
+ sfc = r['sfc']
+ mat_code = r['material_code']
+ if sfc not in sfc_actual_dict:
+ sfc_actual_dict[sfc] = {}
+ sfc_actual_dict[sfc][mat_code] = {
+ 'actual_qty': r['total_actual_issue'] or 0,
+ 'material_name': r['material_name']
}
- for r in actuals:
- key = (r['sfc'], r['material_code'])
- if key not in reconciliation:
- reconciliation[key] = {
- 'sfc': r['sfc'],
- 'material_code': r['material_code'],
- 'material_name': r['material_name'],
- 'bom_qty': 0,
- 'actual_qty': 0,
- 'diff_qty': 0,
- 'status': 'BOM外发料'
- }
- reconciliation[key]['actual_qty'] += r['total_actual_issue']
- reconciliation[key]['diff_qty'] = round(reconciliation[key]['actual_qty'] - reconciliation[key]['bom_qty'], 4)
+ if match_type == 'official':
+ all_sfcs = set(sfc_bom_dict.keys()).union(set(sfc_actual_dict.keys()))
+ else:
+ all_sfcs = set(sfc_actual_dict.keys())
+
+ # 预先获取所有的 product_code 的 bom_child 树结构
+ product_codes = [p['product_code'] for p in sfc_to_product.values() if p['product_code']]
+ bom_trees = {} # {product_code: list of root children}
+ if product_codes:
+ unique_p_codes = list(set(product_codes))
+ placeholders = ','.join(['?'] * len(unique_p_codes))
+ cursor.execute(f"""
+ SELECT id, parent_material_code, node_material_code, node_material_name, bom_level, parent_node_id, usage_qty
+ FROM bom_child
+ WHERE parent_material_code IN ({placeholders})
+ """, unique_p_codes)
+ all_bom_children = cursor.fetchall()
- if reconciliation[key]['diff_qty'] > 0 and reconciliation[key]['bom_qty'] > 0:
- reconciliation[key]['status'] = '超领发料'
- elif reconciliation[key]['diff_qty'] < 0:
- reconciliation[key]['status'] = '少领发料'
- elif reconciliation[key]['diff_qty'] == 0 and reconciliation[key]['bom_qty'] > 0:
- reconciliation[key]['status'] = '发料正常'
+ from collections import defaultdict
+ child_by_product = defaultdict(list)
+ for row in all_bom_children:
+ child_by_product[row['parent_material_code']].append(dict(row))
- return list(reconciliation.values())
+ for p_code, children in child_by_product.items():
+ nodes_map = {}
+ for row in children:
+ nodes_map[row['id']] = {
+ 'id': f"node_{row['id']}",
+ 'material_code': row['node_material_code'],
+ 'material_name': row['node_material_name'],
+ 'bom_level': row['bom_level'],
+ 'parent_node_id': row['parent_node_id'],
+ 'bom_qty': 0,
+ 'actual_qty': 0,
+ 'diff_qty': 0,
+ 'status': '',
+ 'children': []
+ }
+ root_children = []
+ for row in children:
+ node_id = row['id']
+ parent_id = row['parent_node_id']
+ if parent_id is None:
+ root_children.append(nodes_map[node_id])
+ else:
+ if parent_id in nodes_map:
+ nodes_map[parent_id]['children'].append(nodes_map[node_id])
+ bom_trees[p_code] = root_children
+
+ conn.close()
+
+ result = []
+
+ for sfc in all_sfcs:
+ prod_info = sfc_to_product.get(sfc, {})
+ p_code = prod_info.get('product_code', '')
+ p_name = prod_info.get('product_name', '无工单关联产品')
+
+ sfc_node = {
+ 'id': f"sfc_{sfc}",
+ 'sfc': sfc,
+ 'material_code': p_code,
+ 'material_name': f"【成品】{p_name}",
+ 'bom_qty': '',
+ 'actual_qty': '',
+ 'diff_qty': '',
+ 'status': '',
+ 'children': []
+ }
+
+ boms_for_sfc = sfc_bom_dict.get(sfc, {})
+ actuals_for_sfc = sfc_actual_dict.get(sfc, {})
+ processed_materials = set()
+
+ import copy
+ if p_code in bom_trees:
+ tree_clone = copy.deepcopy(bom_trees[p_code])
+
+ def fill_tree_qty(nodes, sfc_id):
+ for idx, node in enumerate(nodes):
+ node['id'] = f"{sfc_id}_{node['id']}_{idx}" # 确保前端树节点的唯一性
+ m_code = node['material_code']
+
+ bom_info = boms_for_sfc.get(m_code, {})
+ act_info = actuals_for_sfc.get(m_code, {})
+
+ node['bom_qty'] = bom_info.get('bom_qty', 0)
+ node['actual_qty'] = act_info.get('actual_qty', 0)
+
+ node['diff_qty'] = round(node['actual_qty'] - node['bom_qty'], 4)
+ if node['diff_qty'] > 0 and node['bom_qty'] > 0:
+ node['status'] = '超领发料'
+ elif node['diff_qty'] < 0:
+ node['status'] = '少领发料'
+ elif node['diff_qty'] == 0 and node['bom_qty'] > 0:
+ node['status'] = '发料正常'
+ elif node['bom_qty'] == 0 and node['actual_qty'] > 0:
+ node['status'] = 'BOM外发料'
+ elif node['bom_qty'] > 0 and node['actual_qty'] == 0:
+ node['status'] = '未发料'
+ else:
+ node['status'] = '无发料任务'
+
+ processed_materials.add(m_code)
+ fill_tree_qty(node['children'], sfc_id)
+
+ fill_tree_qty(tree_clone, sfc)
+ sfc_node['children'].extend(tree_clone)
+
+ all_m_codes_for_sfc = set(boms_for_sfc.keys()).union(set(actuals_for_sfc.keys()))
+ extra_materials = all_m_codes_for_sfc - processed_materials
+
+ # 将多出的(或者没有树结构的)物料直接作为工单的子节点
+ for i, m_code in enumerate(extra_materials):
+ bom_info = boms_for_sfc.get(m_code, {})
+ act_info = actuals_for_sfc.get(m_code, {})
+
+ bom_q = bom_info.get('bom_qty', 0)
+ act_q = act_info.get('actual_qty', 0)
+ m_name = bom_info.get('material_name') or act_info.get('material_name') or '未知名称'
+
+ diff_q = round(act_q - bom_q, 4)
+
+ status = ''
+ if diff_q > 0 and bom_q > 0:
+ status = '超领发料'
+ elif diff_q < 0:
+ status = '少领发料'
+ elif diff_q == 0 and bom_q > 0:
+ status = '发料正常'
+ elif bom_q == 0 and act_q > 0:
+ status = 'BOM外发料'
+ elif bom_q > 0 and act_q == 0:
+ status = '未发料'
+
+ sfc_node['children'].append({
+ 'id': f"extra_{sfc}_{m_code}_{i}",
+ 'sfc': '',
+ 'material_code': m_code,
+ 'material_name': m_name,
+ 'bom_qty': bom_q,
+ 'actual_qty': act_q,
+ 'diff_qty': diff_q,
+ 'status': status,
+ 'children': []
+ })
+
+ result.append(sfc_node)
+
+ # 根据工单号倒序排列
+ result.sort(key=lambda x: x['sfc'], reverse=True)
+ return result
if __name__ == '__main__':
service = MaterialReconciliationService()
diff --git a/update_html.py b/update_html.py
new file mode 100644
index 0000000..b4c45fd
--- /dev/null
+++ b/update_html.py
@@ -0,0 +1,57 @@
+import re
+
+with open('web_ui/templates/reconciliation.html', 'r', encoding='utf-8') as f:
+ content = f.read()
+
+# Replace activeTab
+content = content.replace("activeTab: 'unmatched'", "activeTab: 'official'")
+
+# Replace the HTML for Tabs
+tabs_start = ''
+tabs_end = ''
+
+start_idx = content.find(tabs_start)
+end_idx = content.find(tabs_end, start_idx) + len(tabs_end)
+
+if start_idx != -1 and end_idx != -1:
+ new_tabs = """
+
+
+ 工单发料明细
+
+
+
'
+
+start_idx = content.find(tabs_start)
+end_idx = content.find(tabs_ti
+start_idx = content.find(tabs
+
+ 导出 Excel
-
-
+
@@ -132,17 +131,17 @@
-
+
-
+
-
@@ -153,7 +152,7 @@
-
+
@@ -181,7 +180,7 @@
el: '#app',
data() {
return {
- activeTab: 'unmatched',
+ activeTab: 'official',
matching: false,
dateRange: { start: '-', end: '-' },
dateRangeSelect: [],
@@ -224,16 +223,34 @@
filteredReconData() {
let data = this.reconData;
- if (this.reconStatusFilter) {
- data = data.filter(item => item.status === this.reconStatusFilter);
- }
- if (this.reconSearch) {
- const keyword = this.reconSearch.toLowerCase();
- data = data.filter(item =>
- (item.sfc && item.sfc.toLowerCase().includes(keyword)) ||
- (item.material_name && item.material_name.toLowerCase().includes(keyword)) ||
- (item.material_code && item.material_code.toLowerCase().includes(keyword))
- );
+
+ if (this.reconSearch || this.reconStatusFilter) {
+ const keyword = this.reconSearch ? this.reconSearch.toLowerCase() : '';
+ const statusFilter = this.reconStatusFilter;
+
+ // 辅助函数:递归检查节点及其子节点是否匹配条件
+ const checkNode = (node) => {
+ let match = true;
+ if (keyword) {
+ match = match && (
+ (node.sfc && node.sfc.toLowerCase().includes(keyword)) ||
+ (node.material_code && node.material_code.toLowerCase().includes(keyword)) ||
+ (node.material_name && node.material_name.toLowerCase().includes(keyword))
+ );
+ }
+ if (statusFilter) {
+ match = match && (node.status === statusFilter);
+ }
+ if (match) return true;
+
+ // 如果当前节点不匹配,检查是否有子节点匹配
+ if (node.children && node.children.length > 0) {
+ return node.children.some(child => checkNode(child));
+ }
+ return false;
+ };
+
+ data = data.filter(row => checkNode(row));
}
return data;
},
@@ -382,22 +399,30 @@
});
},
exportReconData() {
- // 简单的前端 CSV 导出
- const headers = ['工单号(SFC)', '物料代码', '物料名称', 'BOM应发量', '实际发料量', '差异数量', '状态'];
+ // 简单的前端 CSV 导出(支持树形结构展开)
+ const headers = ['层级', '工单号(SFC)', '物料代码', '物料名称', 'BOM应发量', '实际发料量', '差异数量', '状态'];
let csvContent = "data:text/csv;charset=utf-8,\uFEFF" + headers.join(",") + "\n";
- this.filteredReconData.forEach(row => {
+ const processRow = (node, level = 0) => {
+ const indent = " ".repeat(level);
const rowData = [
- row.sfc,
- row.material_code,
- `"${row.material_name || ''}"`,
- row.bom_qty,
- row.actual_qty,
- row.diff_qty,
- row.status
+ level,
+ node.sfc || '',
+ node.material_code || '',
+ `"${indent}${node.material_name || ''}"`,
+ node.bom_qty !== '' ? node.bom_qty : '',
+ node.actual_qty !== '' ? node.actual_qty : '',
+ node.diff_qty !== '' ? node.diff_qty : '',
+ node.status || ''
];
csvContent += rowData.join(",") + "\n";
- });
+
+ if (node.children && node.children.length > 0) {
+ node.children.forEach(child => processRow(child, level + 1));
+ }
+ };
+
+ this.filteredReconData.forEach(row => processRow(row, 0));
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");