基于Django的清册系统

清册系统是用于公司硬件工程师分类登记硬件元器件的工具,属于行业性非常强的工具,功能并不复杂,旨在提供填写录入元器件信息并提供查询。

项目采用Python Django框架,前端使用AdminLTE v3.2.0,数据库Mysql 8,由于是个小系统,一个人开发和后续的维护,也不需要考虑前后端分离。

一、 物料新增

物料新增页面实现硬件工程师按分类录入电子元器件信息以及自动按规则生成编码的功能,一级分类、二级分类和一级品类、二级品类都是级联选项,它们决定了最终编码的生成规则。

二、 物料分类

物料分类页面实现一级分类和二级分类的创建,页面分别由两个表单和列表组成。

三、 物料品类

物料品类页面实现一级品类、二级品类的创建,页面分别由两个表单和两个列表组成,它们决定了物料新增到系统之后的编码前缀。

四、 物料列表

物料列表页面用于展示所有的数据,点击编码,可进入详情页面(如下图)

详情页提供对单物料进行修改和删除操作。

五、 数据初始化

在清册系统没有上线前,硬件的同事都是使用Excel去管理硬件元器件数据的,也包括编码生成,使用系统时需要进行一次数据的初始化,读取Excel数据存入数据库即可,脚本在Django项目内,而且需要调用几个数据Model,所以少不了os.environ[‘DJANGO_SETTINGS_MODULE’] = ‘gatcis_ms.settings’这句。

from io import BytesIO
import os
from datetime import datetime
import time
import xlwt, xlrd
import django

os.environ['DJANGO_SETTINGS_MODULE'] = 'gatcis_ms.settings'
django.setup()
from material.models import AmlModel, BigTypeModel, SmallTypeModel, ParentCategoryModel, SubCategoryModel


def import_excel():
    wb = xlrd.open_workbook(filename=r'C:\Users\zheng\PycharmProjects\gatcis_ms\aml.xls')
    print("读取Excel")
    table = wb.sheets()[0]
    rows = table.nrows
    counter = 1
    err_list = []
    try:
        if rows > 1:
            for i in range(1, rows):
                row_values = table.row_values(i)
                print("-----------------")
                pn = row_values[0]
                if pn_unique(pn):
                    err_list.append(pn)
                    print(err_list)
                    continue
                parent_name = row_values[1]
                sub_name = row_values[2]
                pid = parent_select_insert(parent_name)
                aml = {
                    "pn": pn,
                    "parent_category_id": pid,
                    "sub_category_id": sub_select_insert(category_name=sub_name, pid=pid),
                    "big_type_id": big_query(pn=pn),
                    "small_type_id": small_query(pn=pn),
                    "product_value": row_values[3],
                    "item_no": row_values[4],
                    "specification": row_values[5],
                    "package": row_values[6],
                    "maker_brand": row_values[7],
                    "status": True,
                    "is_show": True,
                    "remark": row_values[8],
                    "created_on": datetime.now(),
                    "modify_on": datetime.now(),
                }
                print(aml)
                AmlModel.objects.create(**aml)
                counter += 1
                start_line = '\033[1;31m '
                end_line = '\033[0m'
                print(start_line + "{c} 行数据录入成功".format(c=counter) + end_line)
                print("-----------------")
            print("当前插入数据共计 {c}".format(c=counter))
            print("重复的PN编号:", err_list)
    except Exception as e:
        return e


def parent_select_insert(category_name):
    try:
        parent = ParentCategoryModel.objects.filter(title=category_name).first()
        if parent:
            print("一级类别已经存在")
            return parent.id
        else:
            print("一级类别不存在")
            parent = {
                "title": category_name,
                "description": category_name,
                "created_on": datetime.now(),
                "modify_on": datetime.now(),
            }
            p = ParentCategoryModel.objects.create(**parent)
            print("插入一级分类")
            print("parent_id: {id}".format(id=p.id))
            return p.id
    except Exception as e:
        return e


def sub_select_insert(category_name, pid):
    try:
        sub = SubCategoryModel.objects.filter(title=category_name).first()
        if sub:
            print("二级类别已经存在")
            return sub.id
        else:
            print("二级类别不存在")
            sub = {
                "title": category_name,
                "description": category_name,
                "created_on": datetime.now(),
                "modify_on": datetime.now(),
                "parent_id": pid,
            }
            s = SubCategoryModel.objects.create(**sub)
            print("插入二级分类")
            print("sub_id: {id}".format(id=s.id))
            return s.id
    except Exception as e:
        return e


def big_query(pn):
    big_type = BigTypeModel.objects.filter(code=pn[:3]).first()
    print("获取一级品类成功")
    return big_type.id


def small_query(pn):
    big_type = BigTypeModel.objects.filter(code=pn[:3]).first()
    small_type = SmallTypeModel.objects.filter(code=pn[3], big_type=big_type.id).first()
    print("获取二级品类成功")
    return small_type.id


def pn_unique(pn):
    pn = AmlModel.objects.filter(pn=pn).first()
    if pn:
        start_line = '\033[1;31m '
        end_line = '\033[0m'
        print(start_line + "PN {pn} 重复".format(pn=pn) + end_line)
        return True


# 求一次成功,不报错
import_excel()

六、 部署

由于公司有阿里云的Windows Server,代码托管在IIS上。

七、 总结

整个小项目当时好像只用了一个星期,这还包括了重新翻看Django官方文档,毕竟不是不停重复写一个Django框架,难免会在用时重新翻文档。

清册小工具能够解决硬件工程师录入元器件信息、生成编码,以及数据的共享,它作为一个输入端向后或向上还可以接入到Bom、仓库、ERP等等,系统的推进本质上依赖需求推动,每个公司不同的发展阶段对于工具的需求和定义都不同,实际践行的每一步都非易事。

Python 开发简易 MRP 工具

上周五,生产的同事在进行生产计划时,提出了希望有个工具可以进行 MRP,由于目前公司处在起步阶段,虽然有ERP,也只是使用了该系统的财务部分的模块,仓库以及采购等业务实际都由相关同事以 Excel 进行了大量的比对和管理,对于 MRP 确实需要一个工具。

构想1:采用网页上传 Bom 表格和库存表格,转存进数据库,再进行计算,虽然用户体验稍微好一些,但对于开发而言会需要更多的时间,以及额外的部署开销;

构想2:写一个桌面应用,桌面应用比如 WPF 这个也非常耗时啊,思来想去开发一个控制台应用足够了,Python 去开发,然后再打包编译成一个可执行的 exe;

构想3:完整的开发一个集成库存管理、物料清册、采购与供应商管理、生产管理…不能再想了,太多内容,系统需要有人去使用,公司缺的不是系统,它还不是需要全套系统的阶段。

构想2显得更实际,也符合简单原则,把一个复杂的问题简单解决,这就是最佳的选择。

项目使用了 openpyxl 包来读取和写入 Excel,使用 auto-py-to-exe 打包成 exe 文件,项目目录如下图:excel 目录用于添加以 bom 开头的 Bom 表以及以 w 开头的库存表,result 目录用于输出 MRP 结果,output 是编译成控制台应用的目录;

Bom 表模板只需要两列(型号、用量),其实应该使用物料编码的,用量是单套用量或者是生产的套数,我在控制台中加入了可输入的 Input,如果 Bom 是单套用量,那在 Input 中要输入生产的套数,或者输入 1,由于表格有表头,所以代码取数从第 2 行开始。

库存表格其实只需要型号和数量,同事提供的表格内容较多,我在代码中定位了型号所在的列 6 以及库存所在的列 20,原本考虑复杂场景应该还有多仓库的概念,额…稍微复杂一些,暂时不往代码里面加逻辑,下图中增加T列是因为 R 列数据由公式计算得来,它不是 int 类。

项目可实现读取指定目录下的多份 Bom,以及一份库存表,再依次对单份 Bom 所需物料进行库存校验,然后虚拟库存占用,再进行下一份的 Bom 所需物料校验,然后将结果输出给不同的 sheet。

完整代码如下:

import openpyxl
import os
# 获取表格目录下有多少个表格,形成文件名的 List
files = os.listdir("excel")
# 获取表格目录下所有的 Bom 表,形成文件名的 List
bom_files = [f for f in files if f.startswith('bom') and f.endswith('.xlsx')]  # 获取前缀为 bom 的表格
# 获取表格目录下所有的库存表,形成文件名 List
material_inventory_files = [f for f in files if f.startswith('w') and f.endswith('.xlsx')]  # 获取前缀为 w 的表格
# 读取 Bom 表
def get_bom(bn: str, su: int):
    """
    获取 Bom Excel 表中的物料数据
    :param su: 套数,用于乘以 Bom 表物料的一套用量
    :param bn: Bom Excel 表的名称
    :return: 返回 Bom Excel 的物料和需求量字典
    """
    return read_excel(bn, False, su)
# 读取库存表
def get_material_inventory(mn: str):
    """
    获取库存 Excel 表中的物料数据
    :param mn: 库存 Excel 表的名称
    :return: 返回库存 Excel 的物料和库存量字典
    """
    return read_excel(mn, True)
# 公用读取表格函数
def read_excel(excel: str, stock: bool = False, suite: int = 1):
    """
    公用的读取 Excel 表方法
    :param suite: Bom 表中的一套用量需要乘的套数
    :param stock: 一个 bool 参数,用于判断是读取库存还是读取 Bom,由于数据列不同
    :param excel: 需要读取的 Excel 表的名称
    :return: 返回一个 Bom 或库存的字典数据
    """
    workbook = openpyxl.load_workbook(f'excel/{excel}')
    sheet = workbook.active
    data = {}
    if stock:
        for row in range(2, sheet.max_row + 1):
            cell_1 = sheet.cell(row, 6).value
            cell_2 = sheet.cell(row, 20).value
            data.update({cell_1: cell_2})
    else:
        for row in range(2, sheet.max_row + 1):
            cell_1 = sheet.cell(row, 1).value
            cell_2 = sheet.cell(row, 2).value * suite
            data.update({cell_1: cell_2})
    return data
# 比对需求物料和库存并输出结果
def calculator(bd: dict, sd: dict):
    """
    计算 Bom 用量需求和库存的 MRP
    :param sd: 库存字典
    :param bd: Bom 需求量字典
    :return: 返回一个 MRP 结果的列表
    """
    res = []
    for bk, bv in bd.items():
        if bk in sd:
            if bv > sd[bk]:
                shortage = bv - sd[bk]
                row = {'type': bk, 'dosage': bv, 'inventory': sd[bk], 'shortage': shortage, 'remain': 0}
                res.append(row)
                sd[bk] = 0
            else:
                remain = sd[bk] - bv
                row = {'type': bk, 'dosage': bv, 'inventory': sd[bk], 'shortage': 0, 'remain': remain}
                res.append(row)
                sd[bk] = remain
        else:
            row = {'type': bk, 'dosage': bv, 'inventory': 0, 'shortage': bv, 'remain': 0}
            res.append(row)
    return res
# 生成结果
def generate_result(sheet: str, mrp: list):
    """
    将 MRP 结果输出到 Excel
    :param sheet: 按 Bom 名称给 sheet 命名
    :param mrp: MRP 的结果列表
    :return: 输出 Excel,无返回值
    """
    _sheet = wb.create_sheet(sheet)
    _sheet['A1'] = '型号'
    _sheet['B1'] = '用量'
    _sheet['C1'] = '库存'
    _sheet['D1'] = '欠缺'
    _sheet['E1'] = '剩余'
    for row in mrp:
        _sheet.append([row['type'], row['dosage'], row['inventory'], row['shortage'], row['remain']])
# 库存的内存变量
material_inventory = {}
# 公用的结果对象
wb = openpyxl.Workbook()
if __name__ == '__main__':
    _bwl = {}
    _mwl = {}
    if len(bom_files) > 1:
        while True:
            for w in material_inventory_files:
                _mwl = get_material_inventory(w)
                material_inventory = _mwl
                break
            for b in bom_files:
                while True:
                    try:
                        su = int(input(f"请输入 {b} 生产数量,只能是整数:"))
                        break
                    except ValueError:
                        print("输入错误,请重新输入整数")
                _bwl = get_bom(b, su)
                generate_result(b, calculator(_bwl, _mwl))
                print(f"{b} MRP 运算结束")
            wb.save('result/res.xlsx')  # 保存输出到 Excel
            break
    else:
        print("未检测到 Bom 表格,请确保 excel 目录下有以 bom 开头的 Excel 表格")
    input("按任意键结束...")