Compare commits

...

8 Commits

Author SHA1 Message Date
s.steuer cf30876e55 bat zur Ausführung des Python-Scripts ergänzt 2025-08-14 17:52:02 +02:00
s.steuer a7e8ad49a7 Script nochmal komplett überarbeitet und von verschiebelogik.py in process_bom.py umbenannt. Auch Die Logik wurde komplett überarbeitet. Die komplette Erstellung der finalen Stückliste läuft nun prozedural ausschließlich über SQL-Befehle ab. Damit soll das Verhalten von PL/SQL in SIVAS nachgestellt werden, um die Implementierung im SIVAS-Workflow zu erleichtern. Die Logik kommt nun komplett ohne Loops aus, d.h. die SQL-Abfragen sammeln nun direkt alle Positionsnummern, die jedem Logik-Fall zugeordnet sind. Dies verbessert auch die Performance. 2025-08-14 17:51:21 +02:00
s.steuer d27928a591 SQL-Datei aus gleichem Grund wie CSV-Dateien verschoben, siehe Commit 7fd34f4 2025-08-14 17:45:43 +02:00
s.steuer 7fd34f4d12 CSV's in neues in-Verzeichnis verschoben, da das Programm jetzt die fertigen Stücklisten auch als CSVs ausgeben kann. Um Eingabe- und Ausgabedateien klar voneinander zu trennen existieren nun für in- und out-Dateien unterschiedliche Unterverzeichnisse. 2025-08-14 17:44:02 +02:00
s.steuer 6844c806c1 Weitere Umgebungsvariablen für Logs sowie input- und output-Dateien im data-Verzeichnis ergänzt 2025-08-14 17:41:10 +02:00
s.steuer 54566bdedd Schaltername in Konfiguration für Konsolenausgabe korrigiert und Konfiguration umbenannt + Debug-Konfiguration für Ausgabe der neuen BOM als CSV ergänzt 2025-08-14 17:39:56 +02:00
s.steuer 92f20c8456 Datein in data/out und py_cache-Dateien sollen nicht getracked werden 2025-08-14 17:35:30 +02:00
s.steuer 15866d993b install.bat für Python virtual environment sowie etwaige Dependencies, die in einer requirements.txt in data definiert werden, erstellt 2025-08-14 17:33:16 +02:00
23 changed files with 822 additions and 712 deletions
+2
View File
@@ -1,2 +1,4 @@
/.venv /.venv
/work /work
/lib/__pycache__
/data/out
+10 -1
View File
@@ -13,7 +13,16 @@
"justMyCode": true "justMyCode": true
}, },
{ {
"name": "ConsoleOutputNewBOM", "name": "OutputInConsole",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": true,
"args": ["000000382", "cmd"]
},
{
"name": "OutputAsCSV",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "${file}", "program": "${file}",
+5
View File
@@ -0,0 +1,5 @@
@echo off
CALL setenv.bat
CALL ..\.venv\Scripts\activate.bat
python %PROJECT_LIB%\process_bom.py %*
CALL ..\.venv\Scripts\deactivate.bat
+19
View File
@@ -0,0 +1,19 @@
@echo off
call setenv.bat
if not exist %PROJECT%\.venv (
ECHO Initialisiere Python virtual environment...
python -m venv %PROJECT%\.venv --upgrade-deps
ECHO Erfolgreich.
call %PROJECT%\.venv\Scripts\activate.bat
if exist %PROJECT_LIB%\requirements.txt (
ECHO Installiere erforderliche Python Packages...
pip install -r %PROJECT_LIB%/requirements.txt -q
ECHO Erfolgreich
)
call %PROJECT%\.venv\Scripts\deactivate.bat
) else (
ECHO Virtual environment liegt bereits vor, siehe %PROJECT%\.venv!
)
+18 -16
View File
@@ -1,21 +1,23 @@
@echo off @echo off
REM ~dp0 steht für das Verzeichnis, in der diese Datei liegt PUSHD %~dp0\..
pushd %~dp0\..
set PROJECT=%cd% SET PROJECT=%cd%
set PROJECT_BIN=%PROJECT%\bin SET PROJECT_BIN=%PROJECT%\bin
set PROJECT_CFG=%PROJECT%\cfg SET PROJECT_CFG=%PROJECT%\cfg
set PROJECT_DOC=%PROJECT%\doc SET PROJECT_DATA=%PROJECT%\data
set PROJECT_LIB=%PROJECT%\lib SET PROJECT_DATA_IN=%PROJECT%\data\in
set PROJECT_DATA=%PROJECT%\data SET PROJECT_DATA_OUT=%PROJECT%\data\out
set PROJECT_WORK=%PROJECT%\work SET PROJECT_DOC=%PROJECT%\doc
set PROJECT_TEST=%PROJECT%\testdata SET PROJECT_LIB=%PROJECT%\lib
SET PROJECT_LOG=%PROJECT%\log
SET PROJECT_WORK=%PROJECT%\work
if not exist %PROJECT%\work mkdir %PROJECT%\work IF NOT EXIST %PROJECT%\data mkdir %PROJECT%\data
if not exist %PROJECT%\data mkdir %PROJECT%\data IF NOT EXIST %PROJECT%\data\out mkdir %PROJECT%\data\out
IF NOT EXIST %PROJECT%\log mkdir %PROJECT%\log
IF NOT EXIST %PROJECT%\work mkdir %PROJECT%\work
set DB_PATH=%PROJECT_WORK%\sivas_replika.db SET PATH=%PROJECT_BIN%;%PATH%
set PATH=%PROJECT_BIN%;%PATH% SET DB_PATH=%PROJECT_WORK%\sivas_replika.db
popd POPD
goto :eof
+768
View File
@@ -0,0 +1,768 @@
import argparse
import csv
import os
import sqlite3
import subprocess
def main():
parser = argparse.ArgumentParser(description='Programm erstellt finale SIVAS-Stückliste aus der RuleDesigner-Stücklisten-CSV unter Berücksichtigung der spezifizierten Verschiebelogik!',
prog='process_bom.py',
formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=50))
parser.register("action", "store_after_check", ArgparseDirectoryCheck)
parser.register("action", "store_after_code_check", ArgparseParentcodeCheck)
parser.add_argument("parent_code", action="store_after_code_check", type=str, help="Teilenummer der Baugruppe")
parser.add_argument("--csv_dir", action="store_after_check", default=os.environ.get("PROJECT_DATA_IN"), metavar="dir", help="Verzeichnis, aus dem die Stücklisten-CSV aus RuleDesigner eingelesen werden soll. Default: /data")
subparsers = parser.add_subparsers(title="output type", required=True, help="Legt fest, in welcher Form die Stückliste ausgegeben werden soll. cmd = In Konsole, csv = als csv-Datei", dest="output_type")
sp_cmd = subparsers.add_parser("cmd", help="Ausgabe der fertig bearbeiteten Stückliste in der Konsole")
sp_csv = subparsers.add_parser("csv", help="Ausgabe der fertig bearbeiteten Stückliste als CSV-Datei")
sp_csv.register("action", "store_after_check", ArgparseDirectoryCheck)
sp_csv.add_argument("-v", "--verbose", action="store_true", help="Alle Spalten ausgeben")
sp_cmd.add_argument("-v", "--verbose", action="store_true", help="Alle Spalten ausgeben")
sp_csv.add_argument("-od", "--out_dir", action="store_after_check", default=os.environ.get("PROJECT_DATA_OUT"), metavar="dir", help="Ausgabeverzeichnis fertigen Stücklisten-CSV")
sp_csv.add_argument("-o", "--open_csv", action="store_true", help="CSV-Datei nach dem schreiben öffnen")
args: dict = vars(parser.parse_args())
if os.environ.get("DB_PATH") is not None:
# nur 1 Datenbankinstanz über gesamte Laufzeit (ähnlich Singleton)
db = Database(os.environ.get("DB_PATH"))
else:
raise ValueError("Bitte bin/setenv.bat vor diesem Programm für erforderliche Umgebungsvariablen ausführen!")
try:
old_bom = COMP(db)
new_bom = RD_COMP(db, args["parent_code"])
csv_handler = CSVHandler()
sql_handler = SQLFileHandler()
bom_processing = BOMProcessing(db, args["parent_code"])
# SIVAS "nachbauen"
sql_source_filename = "sivas_replika.sql"
sivas_source_data = sql_handler.read_from_file(filename=sql_source_filename)
old_bom.rebuild_sivas(sivas_source_data)
# CSV-Daten in Datenbank schreiben
csv_data = csv_handler.read_as_dict(filename=f"BOM_{args["parent_code"]}")
new_bom.create_tmp_table()
new_bom.insert_csv_data(csv_data)
# Neue Stückliste erstellen / schreiben
bom_processing.write_new_bom()
if args["output_type"] == "cmd":
if args["verbose"]:
output = repr(new_bom)
print(output)
else:
print(new_bom)
elif args["output_type"] == "csv":
if args["verbose"]:
bom_data = new_bom.get_verbose_data()
else:
bom_data = new_bom.get_compact_data()
filedir = args["out_dir"]
filename = f"BOM_{args["parent_code"]}_final.csv"
filepath = os.path.join(filedir, filename)
csv_handler.write_from_dict(data=bom_data, filedir=filedir, filename=filename)
if args["open_csv"]:
subprocess.run(['cmd', '/c', 'start', '', filepath], shell=True)
else:
NotImplementedError
finally:
db.close()
db.remove() # Damit bei jedem Lauf eine neue Datenbank angelegt wird -> mit :memory: traten Bugs auf
class ArgparseDirectoryCheck(argparse.Action):
def __call__(self, parser, namespace, values: list[str], option_string = None):
if not os.path.isdir(values[0]):
raise NotADirectoryError
setattr(namespace, self.dest, values[0])
class ArgparseParentcodeCheck(argparse.Action):
def __call__(self, parser, namespace, values: str, option_string = None):
csv_filepath = os.path.join(os.environ.get("PROJECT_DATA_IN"), f"BOM_{values}.csv")
if not os.path.isfile(csv_filepath):
raise FileNotFoundError(csv_filepath)
setattr(namespace, self.dest, values)
class FileHandler:
def check_filedir(self, filedir: str) -> str:
if filedir is None:
raise ValueError("Bitte bin/setenv.bat ausführen, oder Verzeichnis angegeben, in dem die Eingabedatei liegt!")
if not os.path.isdir(filedir):
raise NotADirectoryError
return filedir
def check_filepath(self, filepath: str) -> str:
if not os.path.isfile(filepath):
raise FileNotFoundError("Angegebener Dateiname existiert nicht!")
return filepath
class CSVHandler(FileHandler):
def __init__(self, encoding="utf-8", delimiter: str = ";"):
self.delimiter = delimiter
self.encoding = encoding
def read_as_dict(self, filename: str, filedir: str = os.environ.get("PROJECT_DATA_IN"), ) -> list[dict[str]]:
filename = f"{filename.removesuffix(".csv")}.csv"
filedir = self.check_filedir(filedir)
filepath = os.path.join(filedir, filename)
if not filepath:
raise FileNotFoundError("Zur angegebenen Teilenummer existiert keine Stücklisten-CSV!")
with open(filepath, "r", encoding=self.encoding, newline="") as csv_file:
reader = csv.DictReader(csv_file, delimiter=self.delimiter)
required_col_names = ["ParentCode", "Positionsnummer", "ChildCode", "Menge", "Uebergabelaenge"]
if reader.fieldnames != required_col_names:
raise ValueError(f"Spaltennamen falsch. Korrekt: {required_col_names}")
csv_data = []
for line in reader:
line["Positionsnummer"] += "0" # Angleichung Positionsnummer an SIVAS-Format (Pos. * 10)
csv_data.append(line)
return csv_data
def write_from_dict(self, filename: str, data: list[dict[str]], filedir: str = os.environ.get("PROJECT_DATA_OUT")) -> None:
filedir = self.check_filedir(filedir)
filepath = os.path.join(filedir, f"{filename.removesuffix(".csv")}.csv")
with open(filepath, "w", encoding=self.encoding, newline="") as csv_file:
writer = csv.DictWriter(csv_file,
fieldnames=data[0].keys(),
delimiter=self.delimiter)
writer.writeheader()
writer.writerows(data)
self.check_filepath(filepath)
print(f"CSV geschrieben: {filepath}")
class SQLFileHandler(FileHandler):
def __init__(self, encoding: str = "utf-8"):
self.encoding = encoding
def read_from_file(self, filename: str, filedir: str = os.environ.get("PROJECT_DATA_IN")) -> str:
filedir = self.check_filedir(filedir)
filepath = os.path.join(filedir, filename)
filepath = self.check_filepath(filepath)
with open(filepath, "r", encoding="utf-8", newline="") as sql_file:
sql = sql_file.read()
return sql
class Database:
def __init__(self, db_path: str):
if not os.path.isdir(os.path.dirname(db_path)):
raise NotADirectoryError
self.db_path = db_path
self.con = sqlite3.connect(self.db_path, autocommit=True)
self.con.row_factory = self.dict_factory
@staticmethod
def dict_factory(cursor, row):
d = {}
for idx, col in enumerate(cursor.description):
d[col[0]] = row[idx]
return d
def close(self):
self.con.close()
def remove(self):
if os.path.isfile(self.db_path):
os.remove(self.db_path)
class RD_COMP:
"""
Temporäre Tabelle, in die die Daten der Stücklisten-CSV aus RuleDesigner geschrieben werden
Tabelle wird für die Verarbeitung der Stückliste benötigt. Sobald die finale Stückliste steht und in die Tabelle 'COMP'
wird sie nicht mehr benötigt.
"""
def __init__(self, db: Database, parent_code: str):
self.db = db
self.parent_code = parent_code
def __repr__(self):
res = self.get_verbose_data()
output = self._sql_as_ascii_table(res)
return output
def __str__(self):
res = self.get_compact_data()
output = self._sql_as_ascii_table(res)
return output
def create_tmp_table(self) -> None:
"""Schreibt neue Stücklistendaten aus CSV in temporäre Datenbanktabelle 'RD_COMP'."""
self.db.con.execute("""CREATE TABLE RD_COMP (ParentCode TEXT NOT NULL,
Positionsnummer INTEGER NOT NULL,
ChildCode TEXT NOT NULL,
Menge INTEGER NOT NULL,
Uebergabelaenge REAL NOT NULL,
PRIMARY KEY(Positionsnummer)
)
""")
def drop(self):
self.db.con.execute("DROP TABLE RD_COMP")
def insert_csv_data(self, csv_data: list[dict[str]]):
self.db.con.executemany("""
INSERT INTO RD_COMP (ParentCode, Positionsnummer, ChildCode, Menge, Uebergabelaenge)
VALUES (:ParentCode, :Positionsnummer, :ChildCode, :Menge, :Uebergabelaenge)
""", csv_data)
def get_verbose_data(self) -> list[dict[str]]:
res = self.db.con.execute("""
SELECT C.*, R.REGEL, B.BEDINGUNG
FROM COMP C
LEFT JOIN VAR_TEIL_BEDINGUNG_2 B
ON C.ID = B.COMP_ID
LEFT JOIN STKL_REGEL R
ON C.ID = R.COMP_ID
WHERE C.PARENT = :parent_code
ORDER BY C.POSNR
""", {"parent_code": self.parent_code}).fetchall()
return res
def get_compact_data(self) -> list[dict[str]]:
res = self.db.con.execute("""
SELECT C.POSNR, C.KOMPTEILENR, C.ABW_ANZAHL, C.MENGE, C.LAENGE, C.ZUSCHNITT, R.REGEL, B.Bedingung
FROM COMP C
LEFT JOIN VAR_TEIL_BEDINGUNG_2 B
ON C.ID = B.COMP_ID
LEFT JOIN STKL_REGEL R
ON C.ID = R.COMP_ID
WHERE C.PARENT = :parent_code
ORDER BY C.POSNR
""", {"parent_code": self.parent_code}).fetchall()
return res
def _sql_as_ascii_table(self, sql_data: list[dict[str]]):
"""Daten aus NEW-BOM als Tabelle ausgeben"""
headers = list(sql_data[0].keys())
rows = [[str(row.get(h, "") if row.get(h, "") is not None else "") for h in headers] for row in sql_data]
col_widths = [max(len(str(h)), max(len(str(row[i])) for row in rows)) for i, h in enumerate(headers)]
header_line = " | ".join(f"{h:<{col_widths[i]}}" for i, h in enumerate(headers))
separator = "-+-".join("-" * col_widths[i] for i in range(len(headers)))
row_lines = [" | ".join(f"{row[i]:<{col_widths[i]}}" for i in range(len(headers))) for row in rows]
return "\n".join([header_line, separator] + row_lines)
class COMP:
def __init__(self, db: Database):
self.db = db
def rebuild_sivas(self, sql: str) -> None:
self.db.con.executescript(sql)
class BOMProcessing:
def __init__(self, db: Database, parent_code: str):
self.db = db
self.parent_code = parent_code
def _get_codes_case_12_or_13(self) -> None:
"""
Ermittelt alle, die unter Fall 1.2 oder Fall 1.3 fallen.
Es wird dafür geprüft, welche Teilenummern NICHT unter Fall 1.1 fallen.
Wieso wird nicht geprüft, welche Teilenummern in Fall 1.1 liegen?
- Weil die SQL-Abfrage dann deutlich komplexer wird.
Die Prüfung läuft so ab:
1. Teilenr-Längen-Kombinationen aus der alten und neuen Stückliste mit UNION ALL zusammenfügen
2. Mit COUNT ermitteln, wie oft jede Teilenummer-Längen-Kombination in der zusammengefügten Stückliste auftritt
3. Die Teilenummern in die temporäre Tabelle TMP_CODES_CASE_12_OR_13 schreiben, deren Teilenr-Längen-Kombination 1x vorkommen
=> Diese Teilenummern unterliegen entweder Fall 1.2 oder Fall 1.3, aber definitiv nicht Fall 1.1, weil eine Zuordnung zu
Fall 1.1 nur dann möglich ist, wenn alle Teilenr-Längen-Kombinationen exakt 2x auftreten!
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_CODES_CASE_12_OR_13")
self.db.con.execute("CREATE TABLE TMP_CODES_CASE_12_OR_13 (KOMPTEILENR TEXT NOT NULL, PRIMARY KEY (KOMPTEILENR))")
self.db.con.execute("""
WITH combined_bom AS (
SELECT C.KOMPTEILENR, COALESCE(C.LAENGE, 0) AS LAENGE
FROM COMP C
WHERE C.PARENT = :parent_code
UNION ALL
SELECT ChildCode, Uebergabelaenge
FROM RD_COMP
WHERE ParentCode = :parent_code
)
INSERT INTO TMP_CODES_CASE_12_OR_13
SELECT DISTINCT KOMPTEILENR
FROM (
SELECT KOMPTEILENR, LAENGE, COUNT(*) AS ANZAHL
FROM combined_bom
GROUP BY KOMPTEILENR, LAENGE
)
WHERE ANZAHL = 1
""", {"parent_code": self.parent_code})
def _get_codes_case_13(self) -> None:
"""
Ermittelt alle Teilenummern, die unter Fall 1.3 fallen
Fall 1.3 umfasst alle Positionen, die entweder in der neuen Stückliste dazugekommen sind (Fall 1.31) oder deren
Teilenr-Längen-Kombination in beiden Stücklisten vorliegen (Fall 1.32).
Wenn Positionen bereits unter Fall 1.1 oder Fall 1.2 fallen, dann werden sie nicht Fall 1.32 zugeordnet, selbst wenn
sie Fall 1.32 auf diese Positionen zutreffen würde (Vorrangsprinzip).
Die Prüfung läuft so ab:
1. Teilenr-Posnr-Kombinationen aus der alten und neuen Stückliste mit UNION ALL zusammenfügen, sofern sich die Teilenummern in der
Liste an Teilenummern befindet, die unter Fall 1.2 oder Fall 1.3 fallen (siehe Methode _get_codes_case_12_or_13())
2. Mit COUNT ermitteln, wie oft jede Teilenr-Posnr-Kombination in der zusammengefügten Stückliste auftritt
3. Die Teilenummern ausgeben, deren Teilenr-Posnr-Kombination nur 1x vorkommen in die temporäre Tabelle TMP_CODES_CASE_13 schreiben
=> Diese Teilenummern unterliegen definitv Fall 1.3, weil in Fall 1.2 alle Teilenr-Posnr-Kombinationen exakt 2x auftreten müssen!
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_CODES_CASE_13")
self.db.con.execute("CREATE TABLE TMP_CODES_CASE_13 (KOMPTEILENR TEXT NOT NULL, PRIMARY KEY(KOMPTEILENR))")
self.db.con.execute("""
WITH combined_bom AS (
SELECT KOMPTEILENR, POSNR
FROM COMP
WHERE PARENT = :parent_code
AND KOMPTEILENR IN (SELECT * FROM TMP_CODES_CASE_12_OR_13)
UNION ALL
SELECT ChildCode, Positionsnummer
FROM RD_COMP
WHERE ParentCode = :parent_code
AND ChildCode IN (SELECT * FROM TMP_CODES_CASE_12_OR_13)
)
INSERT INTO TMP_CODES_CASE_13 (KOMPTEILENR)
SELECT DISTINCT KOMPTEILENR
FROM (
SELECT KOMPTEILENR, COUNT(*) AS ANZAHL
FROM combined_bom
GROUP BY KOMPTEILENR, POSNR
)
WHERE ANZAHL = 1
""", {"parent_code": self.parent_code})
def _get_case_11_positions(self):
"""
Holt sich die Positionsnummern, die unter Fall 1.1 fallen.
Deren Ermittlung funktioniert so:
1. Teilenummern herausfiltern, die NICHT in der Tabelle TMP_CODES_CASE_12_OR_13 vorliegen
2. Zu den gefundenen Teilenummern:
- Parent-Teilenr
- Posnr. laut neuer Stückliste
- ID laut alter Stückliste
in die temporäre Tabelle TMP_POSNRS_CASE_11 schreiben.
Hinweis:
- ID dient in der späteren UPDATE-Methode dazu, die Positionsnummer zu finden, die aktualisiert werden sollen
- Parent und POSNR werden benötigt, um die neuen Stücklistendaten laut neuer Stückliste identifizieren zu können
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_POSNRS_CASE_11")
self.db.con.execute("""CREATE TABLE TMP_POSNRS_CASE_11 (COMP_ID INTEGER NOT NULL,
PARENT TEXT NOT NULL,
POSNR INTEGER NOT NULL,
PRIMARY KEY(POSNR))""")
self.db.con.execute("""
INSERT INTO TMP_POSNRS_CASE_11 (COMP_ID, PARENT, POSNR)
SELECT C.ID AS COMP_ID, C.PARENT, RD.Positionsnummer AS POSNR
FROM RD_COMP RD
LEFT JOIN TMP_CODES_CASE_12_OR_13 TMP
ON RD.ChildCode = TMP.KOMPTEILENR
LEFT JOIN COMP C
ON RD.ParentCode = C.PARENT
AND RD.ChildCode = C.KOMPTEILENR
AND RD.Uebergabelaenge = COALESCE(C.LAENGE, 0)
WHERE RD.ParentCode = :parent_code
AND TMP.KOMPTEILENR IS NULL
""", {"parent_code": self.parent_code})
def _get_case_12_positions(self) -> None:
"""
Holt sich die Positionsnummern, die unter Fall 1.2 fallen.
Deren Ermittlung funktioniert so:
1. Teilenummern herausfiltern, die in der Tabelle TMP_CODES_CASE_12_OR_13, aber NICHT in Tabelle TMP_CODES_CASE_13 existieren
2. Zu den gefundenen Teilenummern:
- Parent-Teilenr
- Posnr. laut neuer Stückliste
- ID laut alter Stückliste
in die temporäre Tabelle TMP_POSNRS_CASE_12 schreiben.
Hinweis:
- ID dient in der späteren UPDATE-Methode dazu, die Positionsnummer zu finden, die aktualisiert werden sollen
- Parent und POSNR werden benötigt, um die neuen Stücklistendaten laut neuer Stückliste identifizieren zu können
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_POSNRS_CASE_12")
self.db.con.execute("""
CREATE TABLE TMP_POSNRS_CASE_12 (COMP_ID INTEGER NOT NULL,
PARENT TEXT NOT NULL,
POSNR INTEGER NOT NULL,
PRIMARY KEY(POSNR))
""")
self.db.con.execute("""
INSERT INTO TMP_POSNRS_CASE_12 (COMP_ID, PARENT, POSNR)
SELECT C.ID AS COMP_ID, C.PARENT, RD.Positionsnummer AS POSNR
FROM RD_COMP RD
LEFT JOIN TMP_CODES_CASE_13 TMP
ON RD.ChildCode = TMP.KOMPTEILENR
LEFT JOIN COMP C
ON RD.ParentCode = C.PARENT
AND RD.ChildCode = C.KOMPTEILENR
AND RD.Positionsnummer = C.POSNR
WHERE RD.ParentCode = :parent_code
AND TMP.KOMPTEILENR IS NULL
AND RD.Positionsnummer NOT IN (SELECT POSNR FROM TMP_POSNRS_CASE_11);
""", {"parent_code": self.parent_code})
def _get_case_131_positions(self):
"""
Fall 1.31 - Positionsnummern holen, die neu angelegt werden müssen.
Deren Ermittlung funktioniert so:
1. Teilenummern herausfiltern, deren Teilenr-Längen-Kombination nur in der neuen Stückliste existieren und die zudem in der Tabelle
TMP_CODES_CASE_13 vorliegen.
Hinweis:
Es reicht nicht aus, lediglich zu prüfen, ob die Teilenr-Längen-Kombination nur in der neuen Stücklist vorliegt
Grund:
Positionsnummern die unter Fall 1.2 fallen, dürfen in der neuen Stückliste eine andere Teilenr-Längen-Kombination
als in der alten Stückliste aufweisen, d.h. die Teilenr-Längen-Kombi laut neuer Stückliste liegt auch nur dort vor. Trotzdem
werden diese Positionsnummern nicht neu angelegt, sondern von der alten in die neuen Stückliste verschoben!
2. Zu den gefundenen Teilenummern:
- Parent-Teilenr
- Posnr. laut neuer Stückliste
- ID laut alter Stückliste
in die temporäre Tabelle TMP_POSNRS_CASE_131 schreiben.
Hinweis:
- ID dient in der späteren UPDATE-Methode dazu, die Positionsnummer zu finden, die aktualisiert werden sollen
- Parent und POSNR werden benötigt, um die neuen Stücklistendaten laut neuer Stückliste identifizieren zu können
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_POSNRS_CASE_131")
self.db.con.execute("CREATE TABLE TMP_POSNRS_CASE_131 (POSNR INTEGER NOT NULL, PRIMARY KEY(POSNR))")
self.db.con.execute("""
INSERT INTO TMP_POSNRS_CASE_131
SELECT RD.Positionsnummer AS POSNR
FROM RD_COMP RD
LEFT JOIN COMP C
ON RD.ParentCode = C.PARENT
AND RD.ChildCode = C.KOMPTEILENR
AND RD.Uebergabelaenge = COALESCE(C.LAENGE, 0)
WHERE RD.ParentCode = :parent_code
AND RD.ChildCode IN (SELECT * FROM TMP_CODES_CASE_13)
AND C.KOMPTEILENR IS NULL;
""", {"parent_code": self.parent_code})
def _get_case_132_positions(self) -> None:
"""
Fall 1.32 - Positionsnummern holen, die von der alten in die neue Stückliste verschoben werden
Deren Ermittlung funktioniert so:
1. Teilenummern herausfiltern, deren Teilenr-Längen-Kombination in beiden Stückliste existieren und die zudem in der Tabelle
TMP_CODES_CASE_13 vorliegen. Lediglich zu prüfen, ob die Teilenr-Längen-Kombination nur in der neuen Stückliste vorliegt reicht nicht aus!
Grund:
Positionsnummern die Fall 1.1 zugeordnet werden können oder Positionsnummern, die unter Fall 1.2 fallen und deren Länge dabei in beiden
Stücklisten gleich bleibt würden grundlegend ebenfalls unter Fall 1.32 fallen und somit identifiziert werden.
Es gilt jedoch das Vorrangsprinzip, d.h. die Zuordnung zu Fall 1.1 oder Fall 1.2 erfolgt (sofern möglich) immer vor der Zuordnung
zu Fall 1.32!
2. Zu den gefundenen Teilenummern:
- Parent-Teilenr
- Posnr. laut neuer Stückliste
- ID laut alter Stückliste
in die temporäre Tabelle TMP_POSNRS_CASE_132 schreiben.
Hinweis:
- ID dient in der späteren UPDATE-Methode dazu, die Positionsnummer zu finden, die aktualisiert werden sollen
- Parent und POSNR werden benötigt, um die neuen Stücklistendaten laut neuer Stückliste identifizieren zu können
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_POSNRS_CASE_132")
self.db.con.execute("""
CREATE TABLE TMP_POSNRS_CASE_132 (COMP_ID INTEGER NOT NULL,
PARENT TEXT NOT NULL,
POSNR INTEGER NOT NULL,
PRIMARY KEY(POSNR))""")
self.db.con.execute("""
INSERT INTO TMP_POSNRS_CASE_132 (COMP_ID, PARENT, POSNR)
SELECT C.ID AS COMP_ID, C.PARENT, RD.Positionsnummer AS POSNR
FROM RD_COMP RD
INNER JOIN COMP C
ON RD.ParentCode = C.PARENT
AND RD.ChildCode = C.KOMPTEILENR
AND RD.Uebergabelaenge = COALESCE(C.LAENGE, 0)
WHERE RD.ParentCode = :parent_code
AND RD.ChildCode IN (SELECT * FROM TMP_CODES_CASE_13)
""", {"parent_code": self.parent_code})
def delete(self):
"""
Entfernt die Positionsnummern aus der alten Stückliste, die nicht mehr in der neuen Stückliste vorliegen.
Hinweis:
Bei der Ermittlung der zu löschenden Datensätze dürfen die Positionsnummern, die verschoben werden NICHT berücksichtigt werden.
Die alte Lösch-Logik: "Die Teile die in der neuen Stückliste nicht drin sind, werden rausgelöscht" nur noch eingeschränkt anwendbar!
Grund:
Das liegt vor allem an Fall 1.2, denn per Definition darf in diesem Fall die Länge in der neuen Stückliste abweichen, solange alle
Teilenr-Posnr-Kombinationen in beiden Stücklisten identsich sind, siehe hierzu z.B. Pos. 3 in Beispiel 3 unter doc/Beispiele.pdf:
Dort liegt die Länge 200 aus der alten Stückliste nicht mehr in der neuen Stückliste vor. Pos. 3 würde also laut alter Logik gelöscht werden.
Da diese Positionsnummer aber unter Fall 1.2 fällt, wird sie in die neue Stückliste verschoben, d.h. sie darf nicht gelöscht werden!
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_IDS_TO_DELETE")
self.db.con.execute("CREATE TABLE TMP_IDS_TO_DELETE (ID INTEGER NOT NULL, PRIMARY KEY(ID))")
self.db.con.execute("""
WITH COMBINED_UPDATE_DATA AS (
SELECT * FROM TMP_POSNRS_CASE_11
UNION
SELECT * FROM TMP_POSNRS_CASE_12
UNION
SELECT * FROM TMP_POSNRS_CASE_132
)
INSERT INTO TMP_IDS_TO_DELETE
SELECT C.ID
FROM COMP C
LEFT JOIN COMBINED_UPDATE_DATA CUD
ON C.ID = CUD.COMP_ID
WHERE C.PARENT = :parent_code
AND CUD.COMP_ID IS NULL
""", {"parent_code": self.parent_code})
# Entfernung der Stücklistenpositionen aus COMP
self.db.con.execute("DELETE FROM COMP WHERE ID IN (SELECT ID FROM TMP_IDS_TO_DELETE)")
# Entfernung der TOS-Bedingungen aus VAR_TEIL_BEDINGUNG_2
self.db.con.execute("DELETE FROM VAR_TEIL_BEDINGUNG_2 WHERE COMP_ID IN (SELECT ID FROM TMP_IDS_TO_DELETE)")
# Entfernung der TOS-Regeln aus STKL_REGEL
self.db.con.execute("DELETE FROM STKL_REGEL WHERE COMP_ID IN (SELECT ID FROM TMP_IDS_TO_DELETE)")
def update(self):
"""
Aktualisiert die Positionsnummern, die von der alten in die neue Stückliste verschoben werden sollen.
Dies umfasst alle Positionsnummern, die unter die Fälle 1.1, 1.2 und 1.32 fallen. Sie wurden in den Methoden
_get_case_11_positions, _get_case_12_positions & _get_case_132_positions ermittelt und in die temporären
Tabellen TMP_POSNRS_CASE_11, TMP_POSNRS_CASE_12, TMP_POSNRS_CASE_132 geschrieben.
Jetzt passiert folgendes:
1. Die Positionen aus den 3 Tabellen werden zu einer temporären Tabelle, ALL_POSNRS_TO_UPDATE kombiniert.
2. Zu allen Positionen aus in dieser kombinierten, temporären Tabelle werden dann die neuen Stücklisteninformationen aus COMP,
bzw. RD_COMP bestimmt und in eine weitere, temporäre, aber persistente Tabelle TMP_UPDATE_DATA geschrieben.
3. Durchführen der Updates in den Tabellen COMP (für die Stücklisteninformationen), VAR_TEIL_BEDINGUNG_2 (für die TOS-Bedingungen)
und STKL_REGEL für die TOS-Regeln
"""
self.db.con.execute("DROP TABLE IF EXISTS TMP_UPDATE_DATA")
self.db.con.execute("CREATE TABLE TMP_UPDATE_DATA (ID, POSNR, ABW_ANZAHL, MENGE, LAENGE, ZUSCHNITT, DISPO_AKTIV_JN, RUESTCODE, BEDINGUNG, REGEL)")
# Neue Stücklisteninformationen ermitteln
self.db.con.execute("""
WITH ALL_POSNRS_TO_UPDATE AS (
SELECT * FROM TMP_POSNRS_CASE_11
UNION
SELECT * FROM TMP_POSNRS_CASE_12
UNION
SELECT * FROM TMP_POSNRS_CASE_132
)
INSERT INTO TMP_UPDATE_DATA (ID, POSNR, ABW_ANZAHL, MENGE, LAENGE, ZUSCHNITT, DISPO_AKTIV_JN, RUESTCODE, BEDINGUNG, REGEL)
SELECT C.ID,
RD.Positionsnummer AS POSNR,
-- Bestimmt Wert für Spalte 'ABW_ANZAHL': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Menge
END AS ABW_ANZAHL,
-- Bestimmt Wert für Spalte 'MENGE': Berechnung Dezimalwert, wenn Pos. längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN RD.Menge
ELSE RD.Menge * (RD.Uebergabelaenge + COALESCE(C.ZUSCHNITT, 0)) / 1000
END AS MENGE,
-- Bestimmt Wert für Spalte 'LAENGE': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Uebergabelaenge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Uebergabelaenge
END AS LAENGE,
C.ZUSCHNITT,
C.DISPO_AKTIV_JN,
C.RUESTCODE,
B.BEDINGUNG,
R.REGEL
FROM ALL_POSNRS_TO_UPDATE CUD
INNER JOIN COMP C
ON CUD.COMP_ID = C.ID
INNER JOIN RD_COMP RD
ON CUD.PARENT = RD.ParentCode
AND CUD.POSNR = RD.Positionsnummer
LEFT JOIN VAR_TEIL_BEDINGUNG_2 B
ON CUD.COMP_ID = B.COMP_ID
LEFT JOIN STKL_REGEL R
ON CUD.COMP_ID = R.COMP_ID
""")
# Stücklisteninformationen updaten
self.db.con.execute("""
UPDATE COMP
SET POSNR = UD.POSNR,
ABW_ANZAHL = UD.ABW_ANZAHL,
MENGE = UD.MENGE,
LAENGE = UD.LAENGE,
ZUSCHNITT = UD.ZUSCHNITT,
DISPO_AKTIV_JN = UD.DISPO_AKTIV_JN,
RUESTCODE = UD.RUESTCODE
FROM TMP_UPDATE_DATA UD
WHERE COMP.ID = UD.ID
""")
# TOS-Bedingungen updaten
self.db.con.execute("""
UPDATE VAR_TEIL_BEDINGUNG_2
SET BEDINGUNG = UD.BEDINGUNG
FROM TMP_UPDATE_DATA UD
WHERE COMP_ID = UD.ID
""")
# TOS-Regeln updaten
self.db.con.execute("""
UPDATE STKL_REGEL
SET REGEL = UD.REGEL
FROM TMP_UPDATE_DATA UD
WHERE COMP_ID = UD.ID
""")
def insert(self):
"""
Neuanlage aller Positionen, die nur in der neuen Stückliste vorliegen.
Die Positionsnummern wurde bereits in der Methode _get_codes_case_131 ermittelt und in die temporäre Tabelle
TMP_POSNRS_CASE_131 geschrieben.
Hinweise:
- Neue Positionen werden immer ohne TOS-Regeln oder -Bedingungen angelegt, d.h. es erfolgen keine INSERT-Statements
in den Tabellen VAR_TEIL_BEDINGUNG_2 oder STKL_REGEL!
- Zur Verdeutlichung, welche Werte z.B. in der Spalte Ruestcode oder Dispoaktiv nach der Neuanlage vorliegen, werden
die Defaultwerte aus der PARTS-Tabelle geholt. Ob ein solcher INSERT auch in SIVAS erforderlich ist, war zum Zeitpunkt
der Erstellung nicht klar.
"""
self.db.con.execute("""
INSERT INTO COMP (PARENT, POSNR, KOMPTEILENR, ABW_ANZAHL, MENGE, LAENGE, ZUSCHNITT, DISPO_AKTIV_JN, RUESTCODE)
SELECT RD.ParentCode AS PARENT,
RD.Positionsnummer AS POSNR,
RD.ChildCode AS KOMPTEILENR,
-- Bestimmt Wert für Spalte 'ABW_ANZAHL': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Menge
END AS ABW_ANZAHL,
-- Bestimmt Wert für Spalte 'MENGE': Berechnung Dezimalwert, wenn Pos. längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN RD.Menge
ELSE RD.Menge * (RD.Uebergabelaenge + COALESCE(C.ZUSCHNITT,0)) / 1000
END AS MENGE,
-- Bestimmt Wert für Spalte 'LAENGE': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Uebergabelaenge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Uebergabelaenge
END AS LAENGE,
NULL AS ZUSCHNITT,
P.DISPO_AKTIV_JN,
P.RUESTCODE
FROM RD_COMP RD
LEFT JOIN COMP C
ON RD.ChildCode = C.KOMPTEILENR
AND RD.Uebergabelaenge = COALESCE(C.LAENGE, 0)
AND RD.ParentCode = C.PARENT
LEFT JOIN PARTS P
ON RD.ChildCode = P.TEILENR
WHERE C.KOMPTEILENR IS NULL
AND RD.ParentCode = :parent_code
AND RD.Positionsnummer IN (SELECT * FROM TMP_POSNRS_CASE_131)
""", {"parent_code": self.parent_code})
def drop_tmp_tables(self):
self.db.con.execute("DROP TABLE TMP_CODES_CASE_12_OR_13")
self.db.con.execute("DROP TABLE TMP_CODES_CASE_13")
self.db.con.execute("DROP TABLE TMP_POSNRS_CASE_11")
self.db.con.execute("DROP TABLE TMP_POSNRS_CASE_12")
self.db.con.execute("DROP TABLE TMP_POSNRS_CASE_131")
self.db.con.execute("DROP TABLE TMP_POSNRS_CASE_132")
self.db.con.execute("DROP TABLE TMP_IDS_TO_DELETE")
self.db.con.execute("DROP TABLE TMP_UPDATE_DATA")
def write_new_bom(self) -> None:
self._get_codes_case_12_or_13()
self._get_codes_case_13()
self._get_case_11_positions()
self._get_case_12_positions()
self._get_case_131_positions()
self._get_case_132_positions()
self.delete()
self.insert()
self.update()
self.drop_tmp_tables()
if __name__ == "__main__":
main()
-695
View File
@@ -1,695 +0,0 @@
import argparse
import csv
import os
import sqlite3
def main():
parser = argparse.ArgumentParser(description='Programm bearbeitet Stücklisten-CSV aus RuleDesigner, sodass Spaltenüberschreibungen + TOS-Bedingungen und -Regeln (falls vorhanden) mit verschoben werden',
prog='verschiebelogik',
formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=50))
parser.register("action", "store_after_check", ArgparseDirectoryCheck)
parser.add_argument("parent_code", action="store", type=str, help="Teilenummer der Baugruppe")
parser.add_argument("-cid", "--in_csv_dir", action="store_after_check", default=os.environ.get("PROJECT_DATA"), help="Eingabeverzeichnis der Stücklisten-CSV aus RuleDesigner")
subparsers = parser.add_subparsers(title="output type", help="Ausgabe der fertigen Stückliste in Konsole oder als CSV", dest="output_type")
sp_cmd = subparsers.add_parser("console", help="Ausgabe der fertig bearbeiteten Stückliste in der Konsole")
sp_csv = subparsers.add_parser("csv", help="Ausgabe der fertig bearbeiteten Stückliste als CSV-Datei")
sp_csv.add_argument("-v", "--verbose", action="store_true", help="Alle Spalten ausgeben")
sp_csv.register("action", "store_after_check", ArgparseDirectoryCheck)
sp_cmd.add_argument("-v", "--verbose", action="store_true", help="Alle Spalten ausgeben")
sp_csv.add_argument("-cod", "--out_csv_dir", action="store_after_check", default=os.environ.get("PROJECT_WORK"), help="Ausgabeverzeichnis fertigen Stücklisten-CSV")
args: dict = vars(parser.parse_args())
if os.environ.get("DB_PATH") is not None:
# nur 1 Datenbankinstanz über gesamte Laufzeit (ähnlich Singleton)
db = Database(os.environ.get("DB_PATH"))
else:
raise ValueError("Bitte bin/setenv.bat vor diesem Programm für erforderliche Umgebungsvariablen ausführen!")
try:
old_bom = COMP(db)
new_bom = RD_COMP(db, args["parent_code"])
csv_handler = CSVHandler()
sql_handler = SQLFileHandler()
bom_processing = BOMProcessing(db, args["parent_code"])
# SIVAS "nachbauen"
sql_source_filename = "sivas_replika.sql"
sivas_source_data = sql_handler.read_from_file(filename=sql_source_filename)
old_bom.rebuild_sivas(sivas_source_data)
# CSV-Daten in Datenbank schreiben
csv_data = csv_handler.read_as_dict(filename=f"BOM_{args["parent_code"]}")
new_bom.create()
new_bom.insert_csv_data(csv_data)
# Neue Stückliste erstellen / schreiben
bom_processing.write_new_bom()
if args["output_type"] == "console":
if args["verbose"]:
output = repr(new_bom)
print(output)
else:
print(new_bom)
elif args["output_type"] == "csv":
if args["verbose"]:
bom_data = new_bom.get_verbose_data()
else:
bom_data = new_bom.get_compact_data()
csv_handler.write_from_dict(data=bom_data,
filename=f"BOM_{args["parent_code"]}_final",
filedir=args["out_csv_dir"])
else:
NotImplementedError
finally:
db.close()
db.remove() # Damit bei jedem Lauf eine neue erstellt wird -> mit :memory: traten Bugs auf
class ArgparseDirectoryCheck(argparse.Action):
def __call__(self, parser, namespace, values: list[str], option_string = None):
if not os.path.isdir(values[0]):
raise NotADirectoryError
setattr(namespace, self.dest, values[0])
class FileHandler:
def check_filedir(self, filedir: str) -> str:
if filedir is None:
raise ValueError("Bitte bin/setenv.bat ausführen, oder Verzeichnis angegeben, in dem die Eingabedatei liegt!")
if not os.path.isdir(filedir):
raise NotADirectoryError
return filedir
def check_filepath(self, filepath: str) -> str:
if not os.path.isfile(filepath):
raise FileNotFoundError("Angegebener Dateiname existiert nicht!")
return filepath
class CSVHandler(FileHandler):
def __init__(self, encoding="utf-8", delimiter: str = ";"):
self.delimiter = delimiter
self.encoding = encoding
def read_as_dict(self, filename: str, filedir: str = os.environ.get("PROJECT_DATA"), ) -> list[dict[str]]:
filename = f"{filename.removesuffix(".csv")}.csv"
filedir = self.check_filedir(filedir)
filepath = os.path.join(filedir, filename)
if not filepath:
raise FileNotFoundError("Zur angegebenen Teilenummer existiert keine Stücklisten-CSV!")
with open(filepath, "r", encoding=self.encoding, newline="") as csv_file:
reader = csv.DictReader(csv_file, delimiter=self.delimiter)
required_col_names = ["ParentCode", "Positionsnummer", "ChildCode", "Menge", "Uebergabelaenge"]
if reader.fieldnames != required_col_names:
raise ValueError(f"Spaltennamen falsch. Korrekt: {required_col_names}")
csv_data = []
for line in reader:
line["Positionsnummer"] += "0" # Angleichung Positionsnummer an SIVAS-Format (Pos. * 10)
csv_data.append(line)
return csv_data
def write_from_dict(self, filename: str, data: list[dict[str]], filedir: str = os.environ.get("PROJECT_WORK")) -> None:
filedir = self.check_filedir(filedir)
filepath = os.path.join(filedir, f"{filename.removesuffix(".csv")}.csv")
with open(filepath, "w", encoding=self.encoding, newline="") as csv_file:
writer = csv.DictWriter(csv_file,
fieldnames=data[0].keys(),
delimiter=self.delimiter)
writer.writeheader()
writer.writerows(data)
self.check_filepath(filepath)
class SQLFileHandler(FileHandler):
def __init__(self, encoding: str = "utf-8"):
self.encoding = encoding
def read_from_file(self, filename: str, filedir: str = os.environ.get("PROJECT_DATA")) -> str:
filedir = self.check_filedir(filedir)
filepath = os.path.join(filedir, filename)
filepath = self.check_filepath(filepath)
with open(filepath, "r", encoding="utf-8", newline="") as sql_file:
sql = sql_file.read()
return sql
class Database:
def __init__(self, db_path: str):
if not os.path.isdir(os.path.dirname(db_path)):
raise NotADirectoryError
self.db_path = db_path
self.con = sqlite3.connect(self.db_path, autocommit=True)
self.con.row_factory = self.dict_factory
@staticmethod
def dict_factory(cursor, row):
d = {}
for idx, col in enumerate(cursor.description):
d[col[0]] = row[idx]
return d
def close(self):
self.con.close()
def remove(self):
if os.path.isfile(self.db_path):
os.remove(self.db_path)
class RD_COMP:
"""
Temporäre Tabelle, in die die Daten der Stücklisten-CSV aus RuleDesigner geschrieben werden
Tabelle wird für die Verarbeitung der Stückliste benötigt. Sobald die finale Stückliste steht und in die Tabelle 'COMP'
wird sie nicht mehr benötigt.
"""
def __init__(self, db: Database, parent_code: str):
self.db = db
self.parent_code = parent_code
def __repr__(self):
res = self.get_verbose_data()
output = self._sql_as_ascii_table(res)
return output
def __str__(self):
res = self.get_compact_data()
output = self._sql_as_ascii_table(res)
return output
def create(self) -> None:
"""Schreibt neue Stücklistendaten aus CSV in temporäre Datenbanktabelle 'RD_COMP'."""
self.db.con.execute("""CREATE TABLE RD_COMP (ParentCode TEXT NOT NULL,
Positionsnummer INTEGER NOT NULL,
ChildCode TEXT NOT NULL,
Menge INTEGER NOT NULL,
Uebergabelaenge REAL NOT NULL,
PRIMARY KEY(Positionsnummer))""")
def drop(self):
pass
def insert_csv_data(self, csv_data: list[dict[str]]):
self.db.con.executemany(""" INSERT INTO RD_COMP (ParentCode, Positionsnummer, ChildCode, Menge, Uebergabelaenge)
VALUES (:ParentCode, :Positionsnummer, :ChildCode, :Menge, :Uebergabelaenge)""", csv_data)
def get_all_bom_data(self) -> list[dict[str]]:
res = self.db.con.execute("SELECT * FROM RD_COMP WHERE ParentCode = :parent ORDER BY Positionsnummer",
{"parent": self.parent_code}).fetchall()
return res
def get_verbose_data(self) -> list[dict[str]]:
res = self.db.con.execute("""
SELECT C.*,
R.REGEL,
B.BEDINGUNG
FROM COMP C
LEFT JOIN VAR_TEIL_BEDINGUNG_2 B
ON C.ID = B.COMP_ID
LEFT JOIN STKL_REGEL R
ON C.ID = R.COMP_ID
WHERE C.PARENT = :parent_code
ORDER BY C.POSNR
""", {"parent_code": self.parent_code}).fetchall()
return res
def get_compact_data(self) -> list[dict[str]]:
res = self.db.con.execute("""
SELECT C.POSNR,
C.KOMPTEILENR,
C.ABW_ANZAHL,
C.MENGE,
C.LAENGE,
C.ZUSCHNITT,
R.REGEL,
B.Bedingung
FROM COMP C
LEFT JOIN VAR_TEIL_BEDINGUNG_2 B
ON C.ID = B.COMP_ID
LEFT JOIN STKL_REGEL R
ON C.ID = R.COMP_ID
WHERE C.PARENT = :parent_code
ORDER BY C.POSNR
""", {"parent_code": self.parent_code}).fetchall()
return res
def _sql_as_ascii_table(self, sql_data: list[dict[str]]):
"""SQL-Rohdaten als Tabelle ausgeben"""
headers = list(sql_data[0].keys())
rows = [[str(row.get(h, "") if row.get(h, "") is not None else "") for h in headers] for row in sql_data]
col_widths = [max(len(str(h)), max(len(str(row[i])) for row in rows)) for i, h in enumerate(headers)]
header_line = " | ".join(f"{h:<{col_widths[i]}}" for i, h in enumerate(headers))
separator = "-+-".join("-" * col_widths[i] for i in range(len(headers)))
row_lines = [" | ".join(f"{row[i]:<{col_widths[i]}}" for i in range(len(headers))) for row in rows]
return "\n".join([header_line, separator] + row_lines)
def insert(self, new_data: dict[str, str]) -> None:
self.db.con.executemany("""INSERT INTO COMP (PARENT, POSNR, KOMPTEILENR, DISPO_AKTIV_JN, MENGE, ABW_ANZAHL, LAENGE, ZUSCHNITT, RUESTCODE)
VALUES (:PARENT, :POSNR, :KOMPTEILENR, :DISPO_AKTIV_JN, :MENGE, :ABW_ANZAHL, :LAENGE, :ZUSCHNITT, :RUESTCODE)""",
new_data)
def delete(self, ids: list[dict[str]]) -> None:
self.db.con.executemany("DELETE FROM COMP WHERE ID = :ID", ids)
def update(self, pos_data: list[dict[str]]) -> None:
self.db.con.executemany("""
UPDATE COMP
SET POSNR = :POSNR,
ABW_ANZAHL = :ABW_ANZAHL,
MENGE = :MENGE,
LAENGE = :LAENGE,
ZUSCHNITT = :ZUSCHNITT,
DISPO_AKTIV_JN = :DISPO_AKTIV_JN,
RUESTCODE = :RUESTCODE
WHERE ID = :ID
""", pos_data)
class COMP:
"""Beinhaltet Methoden, zur Interaktion mit Tabelle COMP (gleichnamig wie in SIVAS)"""
def __init__(self, db: Database):
self.db = db
def rebuild_sivas(self, sql: str) -> None:
self.db.con.executescript(sql)
def get_child_ids(self, parent_code: str) -> list[dict[str]]:
res = self.db.con.execute("SELECT ID FROM COMP WHERE PARENT = :parent",
{"parent": parent_code}).fetchall()
return res
class VAR_TEIL_BEDINGUNGEN_2:
"""
Beinhaltet Methoden, zur Interaktion mit Tabelle VAR_TEIL_BEDINGUNG_2 (gleichnamig wie in SIVAS),
in der TOS-Bedingungen gespeichert sind
"""
def __init__(self, db: Database) -> None:
self.db = db
def update(self, data: list[dict[str]]) -> None:
self.db.con.executemany("UPDATE VAR_TEIL_BEDINGUNG_2 SET BEDINGUNG = :BEDINGUNG WHERE COMP_ID = :ID", data)
def delete(self, data: list[dict[str]]) -> None:
self.db.con.executemany("DELETE FROM VAR_TEIL_BEDINGUNG_2 WHERE COMP_ID = :ID", data)
class STKL_REGEL:
"""
Beinhaltet Methoden, zur Interaktion mit Tabelle STKL_REGEL (gleichnamig wie in SIVAS),
in der TOS-Regeln gespeichert sind
"""
def __init__(self, db: Database) -> None:
self.db = db
def update(self, data: list[dict[str]]) -> None:
self.db.con.executemany("UPDATE STKL_REGEL SET REGEL = :REGEL WHERE COMP_ID = :ID", data)
def delete(self, data: list[dict[str]]) -> None:
self.db.con.executemany("DELETE FROM STKL_REGEL WHERE COMP_ID = :ID", data)
class BOMProcessing:
"""
Die Methoden dieser Klasse verarbeiten die Daten aus der RuleDesigner-Stücklisten CSV und initiiert das Schreiben der verarbeiteten
Daten in COMP.
"""
def __init__(self, db: Database, parent_code: str):
self.db = db
self.parent_code = parent_code
self.new_bom = RD_COMP(db, parent_code)
self.old_bom = COMP(db)
self.tos_bedingungen = VAR_TEIL_BEDINGUNGEN_2(db)
self.tos_regeln = STKL_REGEL(db)
def extract_col_data(self, query_results: list[dict[str]], *col_names: tuple) -> list[dict[str]]:
extracted_data = []
for result in query_results:
data = {col_name: result[col_name] for col_name in col_names if col_name in result}
extracted_data.append(data)
return extracted_data
def _get_positions_to_move_old_logic(self) -> set | None:
"""
Gibt alle Positionsnummern zurück, die gemäß alter Logik verschoben werden
"""
res = self.db.con.execute("""
SELECT RD.Positionsnummer AS POS
FROM RD_COMP RD
INNER JOIN COMP C
ON RD.ParentCode = C.PARENT
AND RD.ChildCode = C.KOMPTEILENR
AND RD.Uebergabelaenge = COALESCE(C.LAENGE,0)
WHERE RD.ParentCode = :parent
""", {"parent": self.parent_code}).fetchall()
if res is not None:
res = set([line["POS"] for line in res])
return res
def _check_identical_code_len_combo(self, child_code: str) -> bool:
"""
Prüfung Fall 1.1:
"""
res = self.db.con.execute("""
SELECT CASE
-- Prüfung 1: Liegen alle Teilenr.+Länge-Kombinationen aus der neuen Stückliste auch in der alten Stückliste vor?
WHEN NOT EXISTS (
SELECT 1
FROM RD_COMP
LEFT JOIN COMP
ON RD_COMP.ParentCode = COMP.PARENT
AND RD_COMP.ChildCode = COMP.KOMPTEILENR
AND RD_COMP.Uebergabelaenge = COALESCE(COMP.LAENGE, 0)
WHERE COMP.KOMPTEILENR IS NULL
AND RD_COMP.ChildCode = :child
AND RD_COMP.ParentCode = :parent
)
-- Prüfung 2: Liegen alle Teilenr.+Länge-Kombinationen aus der alten Stückliste auch in der neuen Stückliste vor?
AND NOT EXISTS (
SELECT 1
FROM COMP
LEFT JOIN RD_COMP
ON COMP.PARENT = RD_COMP.ParentCode
AND COMP.KOMPTEILENR = RD_COMP.ChildCode
AND COALESCE(COMP.LAENGE, 0) = RD_COMP.Uebergabelaenge
WHERE RD_COMP.ChildCode IS NULL
AND COMP.KOMPTEILENR = :child
AND COMP.PARENT = :parent
)
-- Wenn Prüfung 1 und 2 zutreffen, dann liegen alle Teilenr.+Länge-Kombinationen in beiden Stücklisten vor (Fall 1.1 trifft zu)
THEN 'True'
ELSE 'False'
END AS 'Fall 1.1'
""",
{"parent": self.parent_code, "child": child_code}).fetchone()
return True if res["Fall 1.1"] == 'True' else False
def _check_same_pos(self, child_code: str) -> bool:
"""
Prüfung Fall 1.2
"""
res = self.db.con.execute("""
SELECT CASE
WHEN NOT EXISTS (
-- Prüfung, ob in der neuen Stückliste an denselben Positionen wie in der alten Stückliste Längenvarianten zur angegebenen Teilenr. vorliegen
SELECT 1
FROM COMP
LEFT JOIN RD_COMP
ON COMP.PARENT = RD_COMP.ParentCode
AND COMP.KOMPTEILENR = RD_COMP.ChildCode
AND COMP.POSNR = RD_COMP.Positionsnummer
WHERE RD_COMP.ChildCode IS NULL
AND COMP.PARENT = :parent
AND COMP.KOMPTEILENR = :child
)
AND NOT EXISTS (
-- Prüfung, ob in der alten Stückliste an denselben Positionen wie in der neuen Stückliste Längenvarianten zur angegebenen Teilenr. vorliegen
SELECT 1
FROM RD_COMP LEFT JOIN COMP
ON RD_COMP.ParentCode = COMP.PARENT
AND RD_COMP.ChildCode = COMP.KOMPTEILENR
AND RD_COMP.Positionsnummer = COMP.POSNR
WHERE COMP.KOMPTEILENR IS NULL
AND RD_COMP.ParentCode = :parent
AND RD_COMP.ChildCode = :child
)
-- Wenn beide Fallprüfungen zutreffend liegt die längenabhängige Teilenummer in beiden Stücklisten gleich oft vor und sie liegt in beiden
-- Stücklisten an den gleichen Positionsnummern
THEN 'True'
ELSE 'False'
END AS 'Fall 1.2'
""",
{"parent": self.parent_code, "child": child_code}).fetchone()
return True if res["Fall 1.2"] == 'True' else False
def _get_update_data_for_diff_pos(self, pos_nr: str) -> list[dict[str]]:
"""
Ermittelt die Daten, die an die neue Position verschoben werden sollen.
(Für Fall 1.1 und Fall 1.3)
"""
res = self.db.con.execute("""
SELECT C.ID,
C.DISPO_AKTIV_JN,
C.ZUSCHNITT,
C.RUESTCODE,
RD.Positionsnummer AS POSNR,
B.BEDINGUNG,
R.REGEL,
-- Bestimmt Wert 'Abw. Anzahl': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Menge
END AS ABW_ANZAHL,
-- Bestimmt Wert für Spalte 'MENGE': Berechnung Dezimalwert, wenn Pos. längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN RD.Menge
ELSE RD.Menge * (RD.Uebergabelaenge + COALESCE(C.ZUSCHNITT,0)) / 1000
END AS MENGE,
-- Bestimmt Wert 'LAENGE': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Uebergabelaenge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Uebergabelaenge
END AS LAENGE
FROM RD_COMP RD
INNER JOIN COMP C
ON RD.ParentCode = C.PARENT
AND RD.ChildCode = C.KOMPTEILENR
AND RD.Uebergabelaenge = COALESCE(C.LAENGE, 0)
LEFT JOIN VAR_TEIL_BEDINGUNG_2 B
ON C.ID = B.COMP_ID
LEFT JOIN STKL_REGEL R
ON C.ID = R.COMP_ID
WHERE RD.ParentCode = :parent
AND RD.Positionsnummer = :posnr
""",
{"parent": self.parent_code, "posnr": pos_nr}).fetchone()
return res
def _get_update_data_for_same_pos(self, pos_nr: str) -> list[dict[str]]:
"""
Ermittelt die Daten, die an dieselbe Position verschoben werden sollen
(Für Fall 1.2)
"""
res = self.db.con.execute("""
SELECT C.ID,
C.ZUSCHNITT,
C.DISPO_AKTIV_JN,
C.RUESTCODE,
RD.Positionsnummer AS POSNR,
B.BEDINGUNG,
R.REGEL,
-- Bestimmt Wert für Spalte 'ABW_ANZAHL': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Menge
END AS ABW_ANZAHL,
-- Bestimmt Wert für Spalte 'MENGE': Berechnung Dezimalwert, wenn Pos. längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN RD.Menge
ELSE RD.Menge * (RD.Uebergabelaenge + COALESCE(C.ZUSCHNITT, 0)) / 1000
END AS MENGE,
-- Bestimmt Wert für Spalte 'LAENGE': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Uebergabelaenge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Uebergabelaenge
END AS LAENGE
FROM RD_COMP RD
INNER JOIN COMP C
ON RD.ParentCode = C.PARENT
AND RD.ChildCode = C.KOMPTEILENR
AND RD.Positionsnummer = C.POSNR
LEFT JOIN VAR_TEIL_BEDINGUNG_2 B
ON C.ID = B.COMP_ID
LEFT JOIN STKL_REGEL R
ON C.ID = R.COMP_ID
WHERE RD.ParentCode = :parent
AND RD.Positionsnummer = :posnr
""",
{"parent": self.parent_code, "posnr": pos_nr}).fetchone()
return res
def _get_ids_to_del(self, ignored_ids: list = []) -> list[dict[str]]:
"""
Die zu löschenden ID's ergeben sich, indem man die ID's der Positionen aus der alten Stückliste betrachtet
und die ID's aus der neuen Stückliste abzieht, die verschoben werden. Positionen, die in der neuen Stückliste
neu dazukommen sind, wirken sich nicht auf die zu löschenden ID's aus!
"""
old_bom_ids = self.old_bom.get_child_ids(self.parent_code)
ids_to_delete = [idx for idx in old_bom_ids if idx["ID"] not in ignored_ids]
return ids_to_delete
def _get_data_for_insert(self, posnr: str) -> list[dict[str]]:
"""
Fall 1.3 (Neuanlage von Positionen)
"""
res = self.db.con.execute("""
SELECT RD.Positionsnummer AS POSNR,
RD.ChildCode AS KOMPTEILENR,
RD.ParentCode AS PARENT,
NULL AS ZUSCHNITT,
P.DISPO_AKTIV_JN,
P.RUESTCODE,
-- Bestimmt Wert für Spalte 'ABW_ANZAHL': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Menge
END AS ABW_ANZAHL,
-- Bestimmt Wert für Spalte 'MENGE': Berechnung Dezimalwert, wenn Pos. längenabhängig, sonst Wert 'Menge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN RD.Menge
ELSE RD.Menge * (RD.Uebergabelaenge + COALESCE(C.ZUSCHNITT,0)) / 1000
END AS MENGE,
-- Bestimmt Wert für Spalte 'LAENGE': NULL, wenn Pos. nicht längenabhängig, sonst Wert 'Uebergabelaenge' aus CSV
CASE
WHEN RD.Uebergabelaenge = 0 THEN NULL
ELSE RD.Uebergabelaenge
END AS LAENGE
FROM RD_COMP RD
LEFT JOIN COMP C
ON RD.ChildCode = C.KOMPTEILENR
AND RD.Uebergabelaenge = COALESCE(C.LAENGE, 0)
AND RD.ParentCode = C.PARENT
-- Da es sich um neue Positionen handelt, werden Defaultwerte verwendet, die z.T. aus dem Teilestamm (PARTS) kommen
LEFT JOIN PARTS P
ON RD.ChildCode = P.TEILENR
WHERE C.KOMPTEILENR IS NULL
AND RD.ParentCode = :parent
AND RD.Positionsnummer = :posnr
""",
{"parent": self.parent_code, "posnr": posnr}).fetchone()
return res
def write_new_bom(self) -> None:
"""
Diese Methode binhaltet die Hauptlogik:
Hier wird festgelegt, welche Positionsnummern eingefügt, gelöscht oder verschoben werden müssen
Am Ende werden die daraus erforderlichen Datenbankaktionen (insert / update / delete) initiiert,
um die neue, fertig verarbeite Stückliste in COMP zu überführen.
"""
update_data_comp = []
insert_data_comp = []
update_data_regel = []
update_data_bedingung = []
ids_to_delete = []
ids_not_to_delete = []
childcodes_case_11 = set() # vermeidet erneute Prüfung einer Teilenummer durch _check_identical_code_len_combo
childcodes_case_12 = set() # vermeidet erneute Prüfung einer Teilenummer durch _check_same_pos()
new_bom_data = self.new_bom.get_all_bom_data()
for pos_data in new_bom_data:
child_code: str = pos_data["ChildCode"]
posnr: int = pos_data["Positionsnummer"]
# Prüfung Fall 1.1
if child_code in childcodes_case_11 or self._check_identical_code_len_combo(child_code) is True:
logic_case = 1.1
childcodes_case_11.add(child_code)
data = self._get_update_data_for_diff_pos(posnr)
# Prüfung Fall 1.2
elif child_code in childcodes_case_12 or self._check_same_pos(child_code) is True:
logic_case = 1.2
childcodes_case_12.add(child_code)
data = self._get_update_data_for_same_pos(posnr)
# Prüfungen Fall 1.3
else:
# Prüfung Fall 1.31 - Verschiebung bestehender Positionen
posnrs_to_move = self._get_positions_to_move_old_logic()
if posnr in posnrs_to_move:
logic_case = 1.31
data = self._get_update_data_for_diff_pos(posnr)
# Prüfung Fall 1.32 - Einfügen neuer Positionen
else:
logic_case = 1.32
data = self._get_data_for_insert(posnr)
# Teilenummern der Stückliste, die verschoben werden (Fal 1.1 / 1.2 / 1.3.1):
# - werden nie gelöscht (konkret: deren ID's in COMP), weil IMMER eine Verschiebung der Positionsnummer erfolgt
if logic_case in (1.1, 1.2, 1.31):
ids_not_to_delete.append(data["ID"])
update_data_comp.append(data)
if logic_case == 1.32:
insert_data_comp.append(data)
# Zu löschende Positionen ermitteln
ids_to_delete = self._get_ids_to_del(ignored_ids=ids_not_to_delete)
# Positionen löschen (falls erforderlich)
if ids_to_delete:
self.new_bom.delete(ids_to_delete)
self.tos_bedingungen.delete(ids_to_delete)
self.tos_regeln.delete(ids_to_delete)
# Positionen einfügen (falls erforderlich). Neue Positionen haben nie TOS-Regeln oder -Bedingungen
if insert_data_comp:
self.new_bom.insert(insert_data_comp)
# Positionen sowie deren TOS-Regeln und -Bedingungen updaten (falls erforderlich)
if update_data_comp:
update_data_regel = self.extract_col_data(update_data_comp, "ID", "REGEL")
update_data_bedingung = self.extract_col_data(update_data_comp, "ID", "BEDINGUNG")
self.new_bom.update(update_data_comp)
self.tos_regeln.update(update_data_regel)
self.tos_bedingungen.update(update_data_bedingung)
if __name__ == "__main__":
main()