diff --git a/frontend/index.html b/frontend/index.html
index 8772f7b..887ed3a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -152,6 +152,26 @@
对账单
+
+
+
+
+
+ 委外管理
+
+
+
+
@@ -475,6 +495,10 @@
+
+
+
+
diff --git a/server/app.py b/server/app.py
index 4501267..e86c752 100644
--- a/server/app.py
+++ b/server/app.py
@@ -314,6 +314,65 @@ def init_db():
updated_at TEXT
)''')
+ # 委外工单表 - 记录委外生产订单
+ c.execute('''CREATE TABLE IF NOT EXISTS outsourcing_orders(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ order_no TEXT NOT NULL UNIQUE,
+ customer_order_no TEXT NOT NULL,
+ product_code TEXT NOT NULL,
+ product_name TEXT NOT NULL,
+ production_qty INTEGER NOT NULL,
+ outsourcing_factory TEXT NOT NULL,
+ delivery_date TEXT NOT NULL,
+ status TEXT DEFAULT 'pending',
+ created_by TEXT,
+ created_at TEXT,
+ updated_at TEXT
+ )''')
+
+ # 委外发料表 - 记录发给外协厂的物料
+ c.execute('''CREATE TABLE IF NOT EXISTS outsourcing_material_issue(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ issue_no TEXT NOT NULL UNIQUE,
+ outsourcing_order_no TEXT NOT NULL,
+ material_code TEXT NOT NULL,
+ material_name TEXT NOT NULL,
+ issue_qty INTEGER NOT NULL,
+ unit TEXT DEFAULT 'pcs',
+ issue_date TEXT NOT NULL,
+ created_by TEXT,
+ created_at TEXT,
+ updated_at TEXT,
+ FOREIGN KEY(outsourcing_order_no) REFERENCES outsourcing_orders(order_no)
+ )''')
+
+ # 委外在制库存表 - 记录发给外协厂但未完工的物料
+ c.execute('''CREATE TABLE IF NOT EXISTS outsourcing_wip_stock(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ outsourcing_order_no TEXT NOT NULL,
+ material_code TEXT NOT NULL UNIQUE,
+ material_name TEXT NOT NULL,
+ wip_qty INTEGER DEFAULT 0,
+ unit TEXT DEFAULT 'pcs',
+ updated_at TEXT,
+ FOREIGN KEY(outsourcing_order_no) REFERENCES outsourcing_orders(order_no)
+ )''')
+
+ # 成品入库表 - 记录外协厂完工的整机入库
+ c.execute('''CREATE TABLE IF NOT EXISTS finished_goods_receipt(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ receipt_no TEXT NOT NULL UNIQUE,
+ outsourcing_order_no TEXT NOT NULL,
+ product_code TEXT NOT NULL,
+ product_name TEXT NOT NULL,
+ receipt_qty INTEGER NOT NULL,
+ receipt_date TEXT NOT NULL,
+ created_by TEXT,
+ created_at TEXT,
+ updated_at TEXT,
+ FOREIGN KEY(outsourcing_order_no) REFERENCES outsourcing_orders(order_no)
+ )''')
+
conn.commit()
# create default admin
c.execute('SELECT id FROM users WHERE username=?', ('admin',))
@@ -7159,6 +7218,437 @@ def import_purchase_demand():
conn.close()
+# ==================== 委外工单管理 API ====================
+
+@app.get('/api/outsourcing-orders')
+@require_login
+@require_any_role('superadmin', 'admin')
+def get_outsourcing_orders():
+ """获取委外工单列表"""
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('SELECT * FROM outsourcing_orders ORDER BY created_at DESC')
+ rows = c.fetchall()
+ conn.close()
+ return jsonify({'list': [dict(r) for r in rows]})
+
+
+@app.post('/api/outsourcing-orders')
+@require_login
+@require_any_role('superadmin', 'admin')
+def create_outsourcing_order():
+ """创建委外工单"""
+ data = request.get_json() or {}
+
+ customer_order_no = data.get('customer_order_no', '').strip()
+ product_code = data.get('product_code', '').strip()
+ product_name = data.get('product_name', '').strip()
+ production_qty = data.get('production_qty', 0)
+ outsourcing_factory = data.get('outsourcing_factory', '').strip()
+ delivery_date = data.get('delivery_date', '').strip()
+
+ if not all([customer_order_no, product_code, product_name, outsourcing_factory, delivery_date]):
+ return jsonify({'error': '请填写所有必填字段'}), 400
+
+ try:
+ production_qty = int(production_qty)
+ if production_qty <= 0:
+ return jsonify({'error': '生产数量必须大于0'}), 400
+ except (ValueError, TypeError):
+ return jsonify({'error': '生产数量必须是有效整数'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+
+ # 生成委外工单号 WW+年月日+4位流水号
+ now = get_beijing_time()
+ date_str = datetime.now().strftime('%Y%m%d')
+ c.execute("SELECT COUNT(*) FROM outsourcing_orders WHERE order_no LIKE ?", (f'WW{date_str}%',))
+ count = c.fetchone()[0]
+ order_no = f'WW{date_str}{str(count + 1).zfill(4)}'
+
+ username = session.get('username', '')
+
+ try:
+ c.execute('''INSERT INTO outsourcing_orders(
+ order_no, customer_order_no, product_code, product_name,
+ production_qty, outsourcing_factory, delivery_date, status,
+ created_by, created_at, updated_at
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?)''', (
+ order_no, customer_order_no, product_code, product_name,
+ production_qty, outsourcing_factory, delivery_date, 'pending',
+ username, now, now
+ ))
+
+ conn.commit()
+ conn.close()
+
+ log('create_outsourcing_order', f'创建委外工单: {order_no}')
+ return jsonify({'ok': True, 'order_no': order_no, 'message': '委外工单创建成功'})
+ except Exception as e:
+ conn.close()
+ return jsonify({'error': f'创建失败: {str(e)}'}), 500
+
+
+@app.get('/api/outsourcing-orders/')
+@require_login
+@require_any_role('superadmin', 'admin')
+def get_outsourcing_order_detail(order_id):
+ """获取委外工单详情及物料明细"""
+ conn = get_db()
+ c = conn.cursor()
+
+ # 获取工单基本信息
+ c.execute('SELECT * FROM outsourcing_orders WHERE id=?', (order_id,))
+ order = c.fetchone()
+
+ if not order:
+ conn.close()
+ return jsonify({'error': '委外工单不存在'}), 404
+
+ # 获取该产品的BOM物料清单
+ c.execute('SELECT * FROM bom WHERE product_code=?', (order['product_code'],))
+ bom_list = c.fetchall()
+
+ # 计算每种物料的需发数量 = BOM用量 * 生产数量
+ materials = []
+ for bom in bom_list:
+ need_qty = int(math.ceil(bom['unit_qty'] * order['production_qty']))
+ materials.append({
+ 'material_code': bom['material_code'],
+ 'material_name': bom['material_name'],
+ 'bom_unit_qty': bom['unit_qty'],
+ 'need_qty': need_qty,
+ 'unit': bom['unit']
+ })
+
+ conn.close()
+
+ return jsonify({
+ 'order': dict(order),
+ 'materials': materials
+ })
+
+
+@app.put('/api/outsourcing-orders/')
+@require_login
+@require_any_role('superadmin', 'admin')
+def update_outsourcing_order(order_id):
+ """更新委外工单"""
+ data = request.get_json() or {}
+
+ conn = get_db()
+ c = conn.cursor()
+
+ c.execute('SELECT * FROM outsourcing_orders WHERE id=?', (order_id,))
+ existing = c.fetchone()
+
+ if not existing:
+ conn.close()
+ return jsonify({'error': '委外工单不存在'}), 404
+
+ update_fields = []
+ update_values = []
+
+ if 'production_qty' in data:
+ update_fields.append('production_qty=?')
+ update_values.append(int(data['production_qty']))
+
+ if 'outsourcing_factory' in data:
+ update_fields.append('outsourcing_factory=?')
+ update_values.append(data['outsourcing_factory'])
+
+ if 'delivery_date' in data:
+ update_fields.append('delivery_date=?')
+ update_values.append(data['delivery_date'])
+
+ if 'status' in data:
+ update_fields.append('status=?')
+ update_values.append(data['status'])
+
+ update_fields.append('updated_at=?')
+ update_values.append(get_beijing_time())
+
+ update_values.append(order_id)
+
+ c.execute(f'UPDATE outsourcing_orders SET {", ".join(update_fields)} WHERE id=?', update_values)
+ conn.commit()
+ conn.close()
+
+ log('update_outsourcing_order', f'更新委外工单 ID: {order_id}')
+ return jsonify({'ok': True, 'message': '更新成功'})
+
+
+@app.delete('/api/outsourcing-orders/')
+@require_login
+@require_any_role('superadmin', 'admin')
+def delete_outsourcing_order(order_id):
+ """删除委外工单"""
+ conn = get_db()
+ c = conn.cursor()
+
+ c.execute('SELECT order_no FROM outsourcing_orders WHERE id=?', (order_id,))
+ row = c.fetchone()
+ if not row:
+ conn.close()
+ return jsonify({'error': '委外工单不存在'}), 404
+
+ c.execute('DELETE FROM outsourcing_orders WHERE id=?', (order_id,))
+ conn.commit()
+ conn.close()
+
+ log('delete_outsourcing_order', f'删除委外工单 ID: {order_id}')
+ return jsonify({'ok': True, 'message': '删除成功'})
+
+
+# ==================== 委外发料管理 API ====================
+
+@app.get('/api/outsourcing-material-issue')
+@require_login
+@require_any_role('superadmin', 'admin')
+def get_material_issue_list():
+ """获取委外发料列表"""
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('SELECT * FROM outsourcing_material_issue ORDER BY created_at DESC')
+ rows = c.fetchall()
+ conn.close()
+ return jsonify({'list': [dict(r) for r in rows]})
+
+
+@app.post('/api/outsourcing-material-issue')
+@require_login
+@require_any_role('superadmin', 'admin')
+def create_material_issue():
+ """创建委外发料单"""
+ data = request.get_json() or {}
+
+ outsourcing_order_no = data.get('outsourcing_order_no', '').strip()
+ materials = data.get('materials', [])
+ issue_date = data.get('issue_date', '').strip()
+
+ if not outsourcing_order_no or not materials or not issue_date:
+ return jsonify({'error': '请填写所有必填字段'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+
+ # 验证委外工单是否存在
+ c.execute('SELECT * FROM outsourcing_orders WHERE order_no=?', (outsourcing_order_no,))
+ order = c.fetchone()
+ if not order:
+ conn.close()
+ return jsonify({'error': '委外工单不存在'}), 404
+
+ # 生成发料单号 FL+年月日+4位流水号
+ now = get_beijing_time()
+ date_str = datetime.now().strftime('%Y%m%d')
+ c.execute("SELECT COUNT(*) FROM outsourcing_material_issue WHERE issue_no LIKE ?", (f'FL{date_str}%',))
+ count = c.fetchone()[0]
+ issue_no = f'FL{date_str}{str(count + 1).zfill(4)}'
+
+ username = session.get('username', '')
+
+ try:
+ for material in materials:
+ material_code = material.get('material_code', '').strip()
+ material_name = material.get('material_name', '').strip()
+ issue_qty = int(material.get('issue_qty', 0))
+ unit = material.get('unit', 'pcs').strip()
+
+ if not material_code or issue_qty <= 0:
+ continue
+
+ # 插入发料记录
+ c.execute('''INSERT INTO outsourcing_material_issue(
+ issue_no, outsourcing_order_no, material_code, material_name,
+ issue_qty, unit, issue_date, created_by, created_at, updated_at
+ ) VALUES(?,?,?,?,?,?,?,?,?,?)''', (
+ issue_no, outsourcing_order_no, material_code, material_name,
+ issue_qty, unit, issue_date, username, now, now
+ ))
+
+ # 扣减期初库存
+ c.execute('SELECT stock_qty FROM initial_stock WHERE material_code=?', (material_code,))
+ stock_row = c.fetchone()
+ if stock_row:
+ new_stock = stock_row['stock_qty'] - issue_qty
+ c.execute('UPDATE initial_stock SET stock_qty=?, updated_at=? WHERE material_code=?',
+ (new_stock, now, material_code))
+
+ # 增加委外在制库存
+ c.execute('SELECT wip_qty FROM outsourcing_wip_stock WHERE material_code=?', (material_code,))
+ wip_row = c.fetchone()
+ if wip_row:
+ new_wip = wip_row['wip_qty'] + issue_qty
+ c.execute('UPDATE outsourcing_wip_stock SET wip_qty=?, updated_at=? WHERE material_code=?',
+ (new_wip, now, material_code))
+ else:
+ c.execute('''INSERT INTO outsourcing_wip_stock(
+ outsourcing_order_no, material_code, material_name, wip_qty, unit, updated_at
+ ) VALUES(?,?,?,?,?,?)''', (
+ outsourcing_order_no, material_code, material_name, issue_qty, unit, now
+ ))
+
+ conn.commit()
+ conn.close()
+
+ log('create_material_issue', f'创建委外发料单: {issue_no}')
+ return jsonify({'ok': True, 'issue_no': issue_no, 'message': '发料成功'})
+ except Exception as e:
+ conn.rollback()
+ conn.close()
+ return jsonify({'error': f'发料失败: {str(e)}'}), 500
+
+
+# ==================== 成品入库管理 API ====================
+
+@app.get('/api/finished-goods-receipt')
+@require_login
+@require_any_role('superadmin', 'admin')
+def get_finished_goods_receipt_list():
+ """获取成品入库列表"""
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('SELECT * FROM finished_goods_receipt ORDER BY created_at DESC')
+ rows = c.fetchall()
+ conn.close()
+ return jsonify({'list': [dict(r) for r in rows]})
+
+
+@app.post('/api/finished-goods-receipt')
+@require_login
+@require_any_role('superadmin', 'admin')
+def create_finished_goods_receipt():
+ """创建成品入库单"""
+ data = request.get_json() or {}
+
+ outsourcing_order_no = data.get('outsourcing_order_no', '').strip()
+ receipt_qty = data.get('receipt_qty', 0)
+ receipt_date = data.get('receipt_date', '').strip()
+
+ if not outsourcing_order_no or not receipt_date:
+ return jsonify({'error': '请填写所有必填字段'}), 400
+
+ try:
+ receipt_qty = int(receipt_qty)
+ if receipt_qty <= 0:
+ return jsonify({'error': '入库数量必须大于0'}), 400
+ except (ValueError, TypeError):
+ return jsonify({'error': '入库数量必须是有效整数'}), 400
+
+ conn = get_db()
+ c = conn.cursor()
+
+ # 验证委外工单是否存在
+ c.execute('SELECT * FROM outsourcing_orders WHERE order_no=?', (outsourcing_order_no,))
+ order = c.fetchone()
+ if not order:
+ conn.close()
+ return jsonify({'error': '委外工单不存在'}), 404
+
+ # 生成入库单号 RK+年月日+4位流水号
+ now = get_beijing_time()
+ date_str = datetime.now().strftime('%Y%m%d')
+ c.execute("SELECT COUNT(*) FROM finished_goods_receipt WHERE receipt_no LIKE ?", (f'RK{date_str}%',))
+ count = c.fetchone()[0]
+ receipt_no = f'RK{date_str}{str(count + 1).zfill(4)}'
+
+ username = session.get('username', '')
+
+ try:
+ # 插入入库记录
+ c.execute('''INSERT INTO finished_goods_receipt(
+ receipt_no, outsourcing_order_no, product_code, product_name,
+ receipt_qty, receipt_date, created_by, created_at, updated_at
+ ) VALUES(?,?,?,?,?,?,?,?,?)''', (
+ receipt_no, outsourcing_order_no, order['product_code'], order['product_name'],
+ receipt_qty, receipt_date, username, now, now
+ ))
+
+ # 获取该产品的BOM,计算物料消耗
+ c.execute('SELECT * FROM bom WHERE product_code=?', (order['product_code'],))
+ bom_list = c.fetchall()
+
+ material_consumption = []
+ for bom in bom_list:
+ material_code = bom['material_code']
+ consumed_qty = int(math.ceil(bom['unit_qty'] * receipt_qty))
+
+ # 扣减委外在制库存
+ c.execute('SELECT wip_qty FROM outsourcing_wip_stock WHERE material_code=?', (material_code,))
+ wip_row = c.fetchone()
+ if wip_row:
+ new_wip = max(0, wip_row['wip_qty'] - consumed_qty)
+ c.execute('UPDATE outsourcing_wip_stock SET wip_qty=?, updated_at=? WHERE material_code=?',
+ (new_wip, now, material_code))
+
+ material_consumption.append({
+ 'material_code': material_code,
+ 'material_name': bom['material_name'],
+ 'consumed_qty': consumed_qty
+ })
+
+ # 增加成品库存(如果需要跟踪成品库存)
+ c.execute('SELECT stock_qty FROM initial_stock WHERE material_code=?', (order['product_code'],))
+ product_stock = c.fetchone()
+ if product_stock:
+ new_stock = product_stock['stock_qty'] + receipt_qty
+ c.execute('UPDATE initial_stock SET stock_qty=?, updated_at=? WHERE material_code=?',
+ (new_stock, now, order['product_code']))
+ else:
+ c.execute('''INSERT INTO initial_stock(
+ material_code, material_name, stock_qty, unit, created_at, updated_at
+ ) VALUES(?,?,?,?,?,?)''', (
+ order['product_code'], order['product_name'], receipt_qty, 'pcs', now, now
+ ))
+
+ conn.commit()
+ conn.close()
+
+ log('create_finished_goods_receipt', f'创建成品入库单: {receipt_no}')
+ return jsonify({
+ 'ok': True,
+ 'receipt_no': receipt_no,
+ 'material_consumption': material_consumption,
+ 'message': '入库成功'
+ })
+ except Exception as e:
+ conn.rollback()
+ conn.close()
+ return jsonify({'error': f'入库失败: {str(e)}'}), 500
+
+
+# ==================== 委外在制库存查询 API ====================
+
+@app.get('/api/outsourcing-wip-stock')
+@require_login
+@require_any_role('superadmin', 'admin')
+def get_outsourcing_wip_stock():
+ """获取委外在制库存列表"""
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('SELECT * FROM outsourcing_wip_stock WHERE wip_qty > 0 ORDER BY updated_at DESC')
+ rows = c.fetchall()
+ conn.close()
+ return jsonify({'list': [dict(r) for r in rows]})
+
+
+# ==================== 客户订单查询 API(用于委外工单关联) ====================
+
+@app.get('/api/customer-orders-for-outsourcing')
+@require_login
+@require_any_role('superadmin', 'admin')
+def get_customer_orders_for_outsourcing():
+ """获取客户订单列表(用于委外工单关联)"""
+ conn = get_db()
+ c = conn.cursor()
+ c.execute('SELECT DISTINCT order_no, customer_name, material FROM customer_orders ORDER BY created_at DESC LIMIT 100')
+ rows = c.fetchall()
+ conn.close()
+ return jsonify({'list': [dict(r) for r in rows]})
+
+
@app.errorhandler(404)
def not_found(e):
# 如果请求的是 HTML 页面(通过 Accept header 判断),返回 index.html