Compare commits
2
Commits
b549063ff6
...
a55774eff2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55774eff2 | ||
|
|
c0dd24b6c5 |
@@ -179,3 +179,9 @@ cython_debug/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Ignore all dotfiles/dotfolders in the home dir (credentials, tool state, etc.)
|
||||
.*
|
||||
|
||||
# rsts sind nur ein zwischenschritt für Testdaten. 1. reproduzierbar, 2. nur kurzfristig nötig
|
||||
rsts/
|
||||
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
# Datenbankmigration von 5.0 auf 8.0
|
||||
aktuell zwei Ansätze:
|
||||
|
||||
- Codeanalyse des Updatemechanismus
|
||||
- Unterschiede im Zustand erkennen nachdem in beiden Versionen die gleichen Testdaten angelegt wurden.
|
||||
Ziel: verstehen, wie sich das Datenbankschema der aktiven Tryton-Module
|
||||
über die Versionen 5.0 bis 8.0 verändert, als Vorbereitung für die
|
||||
eigentliche Migration. Zwei sich ergänzende Ansätze:
|
||||
|
||||
- **[doc/UPDATEANALYSE.md](doc/UPDATEANALYSE.md)** – Codeanalyse des
|
||||
Updatemechanismus: statischer Scan der `__register__`-Methoden aller
|
||||
Module über alle Versions-Branches hinweg. _(fertig)_
|
||||
- **[doc/TESTDATEN.md](doc/TESTDATEN.md)** – Unterschiede im Zustand
|
||||
erkennen, nachdem in zwei frisch installierten Versionen dieselben
|
||||
Testdaten per Proteus angelegt wurden (Snapshot- und später
|
||||
SQL-Dump-Vergleich). _(in Arbeit - siehe Stand im Dokument)_
|
||||
- dazu als Vorarbeit: **[doc/MODELLVERGLEICH.md](doc/MODELLVERGLEICH.md)**
|
||||
– welche Modelle/Felder gibt es je Version überhaupt, und wo hat sich
|
||||
zwischen 5.0 und 8.0 tatsächlich etwas geändert (steuert, wo sich
|
||||
Testdaten-Aufwand lohnt).
|
||||
|
||||
Eigene, von den Standardmodulen abweichende Module (`production`,
|
||||
`stock_product_location`, `stock_split`, `stock_lot_sequence`) sind
|
||||
bewusst noch nicht Teil dieser Analyse - erst wenn die Standardmodule
|
||||
migrierbar sind, werden diese auf 8.0 portiert und die Testdaten-Pipeline
|
||||
darauf erweitert.
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
compare_snapshots.py
|
||||
|
||||
Vergleicht zwei mit create_dataset.py erzeugte Snapshots (z. B. aus
|
||||
Version 6.8 und 7.0, jeweils mit derselben dataset.yaml erzeugt) und
|
||||
markiert:
|
||||
|
||||
- status_changed: Datensatz ist in einer Version fehlgeschlagen, in der
|
||||
anderen nicht -> Kandidat für Format-/
|
||||
Constraint-/Pflichtfeld-Änderung
|
||||
- value_changed: gespeicherter Wert unterscheidet sich zwischen den
|
||||
Versionen, obwohl derselbe Eingabewert verwendet
|
||||
wurde -> Kandidat für automatische Reformatierung
|
||||
- missing_record: Datensatz-Key nur in einem der beiden Snapshots
|
||||
vorhanden (z. B. weil er in dataset.yaml erst später
|
||||
ergänzt wurde)
|
||||
|
||||
Nutzung:
|
||||
python3 compare_snapshots.py snapshot_68.json snapshot_70.json \\
|
||||
--label-a 6.8 --label-b 7.0
|
||||
|
||||
Weiter geht's dann typischerweise so: Modellname aus einem Fund hier
|
||||
nehmen, den zugehörigen Tabellennamen bilden (Punkte durch Unterstriche
|
||||
ersetzen, wie es extract_updates.py auch tut) und in den AST-Analyse-Ergebnissen
|
||||
danach filtern:
|
||||
|
||||
jq '.files[].operations[] | select(.tables[]? == "party_identifier")' \\
|
||||
data/7.0/party.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def load(path):
|
||||
return json.load(open(path, encoding="utf-8"))
|
||||
|
||||
|
||||
def index_by_key(snapshot):
|
||||
return {r["key"]: r for r in snapshot["results"]}
|
||||
|
||||
|
||||
def compare(snap_a, snap_b, label_a, label_b):
|
||||
a = index_by_key(snap_a)
|
||||
b = index_by_key(snap_b)
|
||||
|
||||
findings = []
|
||||
|
||||
for key in sorted(set(a) | set(b)):
|
||||
ra, rb = a.get(key), b.get(key)
|
||||
|
||||
if ra is None or rb is None:
|
||||
findings.append({
|
||||
"key": key,
|
||||
"kind": "missing_record",
|
||||
"detail": f"nur in {label_b if ra is None else label_a} vorhanden",
|
||||
})
|
||||
continue
|
||||
|
||||
if ra["status"] != rb["status"]:
|
||||
findings.append({
|
||||
"key": key,
|
||||
"model": ra["model"],
|
||||
"kind": "status_changed",
|
||||
"detail": (
|
||||
f"{label_a}: {ra['status']} ({ra.get('error')}) | "
|
||||
f"{label_b}: {rb['status']} ({rb.get('error')})"
|
||||
),
|
||||
})
|
||||
continue
|
||||
|
||||
if ra["status"] == "error":
|
||||
continue # beide fehlgeschlagen - nichts auf Feldebene zu vergleichen
|
||||
|
||||
fields_a, fields_b = ra["stored"], rb["stored"]
|
||||
for field in sorted(set(fields_a) | set(fields_b)):
|
||||
va = fields_a.get(field, "<fehlt>")
|
||||
vb = fields_b.get(field, "<fehlt>")
|
||||
if va != vb:
|
||||
findings.append({
|
||||
"key": key,
|
||||
"model": ra["model"],
|
||||
"field": field,
|
||||
"kind": "value_changed",
|
||||
"detail": f"{label_a}={va!r} | {label_b}={vb!r}",
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("snapshot_a")
|
||||
parser.add_argument("snapshot_b")
|
||||
parser.add_argument("--label-a", default="A")
|
||||
parser.add_argument("--label-b", default="B")
|
||||
args = parser.parse_args()
|
||||
|
||||
findings = compare(
|
||||
load(args.snapshot_a), load(args.snapshot_b), args.label_a, args.label_b
|
||||
)
|
||||
|
||||
if not findings:
|
||||
print("Keine Unterschiede gefunden.")
|
||||
return
|
||||
|
||||
by_model = {}
|
||||
for f in findings:
|
||||
by_model.setdefault(f.get("model", "(kein Modell)"), []).append(f)
|
||||
|
||||
for model, items in sorted(by_model.items()):
|
||||
if model == "(kein Modell)":
|
||||
print(f"\n=== {model} - {len(items)} Fund(e) ===")
|
||||
else:
|
||||
table = model.replace(".", "_")
|
||||
print(f"\n=== {model} (Tabelle vermutlich: {table}) - {len(items)} Fund(e) ===")
|
||||
for item in items:
|
||||
field_suffix = f".{item['field']}" if "field" in item else ""
|
||||
print(f" [{item['kind']}] {item['key']}{field_suffix}: {item['detail']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,7 +6,7 @@ from collections import OrderedDict
|
||||
|
||||
|
||||
DATA = Path("data")
|
||||
REPORTS = Path("reports")
|
||||
REPORTS = Path("reports/updateChanges")
|
||||
|
||||
VERSION_DIRS = sorted(
|
||||
[d for d in DATA.iterdir() if d.is_dir()],
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
create_dataset.py
|
||||
|
||||
Legt über Proteus einen deklarativ definierten, reproduzierbaren
|
||||
Testdatensatz an und liest ihn danach FRISCH VOM SERVER zurück (nicht aus
|
||||
dem lokalen Proteus-Cache) - damit automatische Reformatierung durch die
|
||||
Zielversion sichtbar wird. Ergebnis wird als JSON-Snapshot gespeichert.
|
||||
|
||||
Gedacht, um dieselbe dataset.yaml gegen zwei Tryton-Versionen laufen zu
|
||||
lassen (zwei getrennte, frisch installierte Datenbanken) und die beiden
|
||||
Snapshots danach mit compare_snapshots.py zu vergleichen.
|
||||
|
||||
Nutzung:
|
||||
python3 create_dataset.py --config dataset.yaml \\
|
||||
--database mydb_68 --output snapshot_68.json
|
||||
|
||||
python3 create_dataset.py --config dataset.yaml \\
|
||||
--database mydb_70 --output snapshot_70.json
|
||||
|
||||
python3 compare_snapshots.py snapshot_68.json snapshot_70.json \\
|
||||
--label-a 6.8 --label-b 7.0
|
||||
|
||||
WICHTIG: Dieses Skript wurde nicht gegen einen echten Tryton-Server
|
||||
getestet (dafür fehlt hier die Umgebung) - nur auf Syntax geprüft. Bitte
|
||||
bei dir gegenlesen, insbesondere proteus_config.set_trytond(...) an eure
|
||||
tatsächliche Verbindungsart anpassen (lokal via Unix-Socket/Config-Datei
|
||||
oder per XML-RPC/JSON-RPC über Netzwerk - die Proteus-API unterscheidet
|
||||
sich da je nach Version leicht).
|
||||
|
||||
Feld-Umbenennungen (FIELD_RENAMES):
|
||||
Da dieselbe dataset.yaml gegen mehrere Versionen laufen soll, aber Felder
|
||||
zwischen Versionen umbenannt werden können (bestätigt: stock.move.uom ->
|
||||
stock.move.unit zwischen 5.0 und 8.0), wird pro Modell zuerst geprüft, ob
|
||||
der im YAML angegebene Feldname auf dem jeweiligen Server tatsächlich
|
||||
existiert (via fields_get()). Falls nicht, aber ein bekannter Alias aus
|
||||
FIELD_RENAMES existiert UND der Alias-Name dort existiert, wird der Alias
|
||||
verwendet - der Datensatz landet dann inhaltlich identisch in beiden
|
||||
Datenbanken, nur eben unter dem jeweils richtigen Feldnamen. Im
|
||||
JSON-Snapshot ("stored") wird trotzdem immer der ORIGINALE YAML-Feldname
|
||||
als Key verwendet, damit compare_snapshots.py die Werte über beide
|
||||
Versionen hinweg unter demselben Key vergleicht.
|
||||
|
||||
Ein wirklich unbekanntes Feld (kein Treffer in FIELD_RENAMES, oder Alias
|
||||
existiert dort ebenfalls nicht) wird NICHT stillschweigend übergangen,
|
||||
sondern lässt den Record ganz normal mit AttributeError fehlschlagen -
|
||||
das ist ja gerade das gesuchte Signal für eine echte Schema-Änderung.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import decimal
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
from proteus import Model, config as proteus_config
|
||||
|
||||
|
||||
# Bekannte Feld-Umbenennungen zwischen Tryton-Versionen: Modell -> {einer
|
||||
# der beiden Feldnamen: der jeweils andere}. Nur eine Richtung eintragen
|
||||
# (z. B. die 5.0->8.0-Richtung) - _bidirectional_field_renames() ergänzt
|
||||
# die Rückrichtung automatisch, damit die Auflösung unabhängig davon
|
||||
# funktioniert, mit welcher Version die dataset.yaml ursprünglich gebaut
|
||||
# wurde. Bei Bedarf ergänzen, sobald ein compare_snapshots.py-Lauf einen
|
||||
# weiteren Fall aufdeckt.
|
||||
FIELD_RENAMES = {
|
||||
"stock.move": {"uom": "unit"},
|
||||
}
|
||||
|
||||
|
||||
def _bidirectional_field_renames(field_renames):
|
||||
result = {}
|
||||
for model_name, renames in field_renames.items():
|
||||
both = dict(renames)
|
||||
both.update({new: old for old, new in renames.items()})
|
||||
result[model_name] = both
|
||||
return result
|
||||
|
||||
|
||||
BIDIRECTIONAL_FIELD_RENAMES = _bidirectional_field_renames(FIELD_RENAMES)
|
||||
|
||||
|
||||
def resolve_field_name(RecordModel, model_name, field_name, fields_cache):
|
||||
"""Liefert den auf DIESEM Server tatsächlich existierenden Feldnamen.
|
||||
|
||||
Existiert field_name direkt (fields_get()), wird er unverändert
|
||||
zurückgegeben. Sonst wird in FIELD_RENAMES nachgeschaut (in beide
|
||||
Richtungen), ob ein bekannter Alias existiert UND auf diesem Server
|
||||
tatsächlich vorhanden ist. Andernfalls bleibt field_name unverändert,
|
||||
damit ein wirklich unbekanntes Feld weiterhin als Fehler auffällt.
|
||||
"""
|
||||
if model_name not in fields_cache:
|
||||
try:
|
||||
fields_cache[model_name] = set(RecordModel.fields_get().keys())
|
||||
except Exception:
|
||||
fields_cache[model_name] = None # unbekannt - keine Alias-Auflösung möglich
|
||||
|
||||
available = fields_cache[model_name]
|
||||
if available is None or field_name in available:
|
||||
return field_name
|
||||
|
||||
alias = BIDIRECTIONAL_FIELD_RENAMES.get(model_name, {}).get(field_name)
|
||||
if alias and alias in available:
|
||||
return alias
|
||||
return field_name
|
||||
|
||||
|
||||
def load_spec(path):
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
if path.endswith((".yaml", ".yml")):
|
||||
if yaml is None:
|
||||
raise RuntimeError(
|
||||
"PyYAML nicht installiert (pip install pyyaml) - "
|
||||
"oder --config auf eine .json-Datei zeigen lassen."
|
||||
)
|
||||
return yaml.safe_load(text)
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def resolve_value(value, created, search_cache):
|
||||
"""
|
||||
Löst Referenzen der Form {"$ref": "irgendein_key"} auf bereits in
|
||||
diesem Lauf erzeugte Datensätze auf (z. B. um eine Adresse mit der
|
||||
vorher angelegten Party zu verknüpfen). Referenzen können nur auf
|
||||
Records verweisen, die im YAML VOR der aktuellen Stelle stehen.
|
||||
|
||||
Versteht außerdem die von rst_to_dataset.py erzeugten Marker für
|
||||
Werte, die kein einfacher JSON-Typ sind:
|
||||
{"$decimal": "12.34"} -> decimal.Decimal("12.34")
|
||||
{"$date": "2024-01-15"} -> datetime.date(2024, 1, 15)
|
||||
{"$search": {"model": "product.uom",
|
||||
"domain": [["name", "=", "Unit"]]}}
|
||||
-> sucht den Record live auf dem
|
||||
Zielserver (Model.find(domain)) -
|
||||
für Stammdaten, die nicht Teil des
|
||||
Testdatensatzes sind, sondern beim
|
||||
Modul-Aktivieren per XML-Fixture
|
||||
angelegt werden (Länder, Währungen,
|
||||
Einheiten, Standard-Lagerorte, ...).
|
||||
Wirft einen Fehler, wenn nichts
|
||||
gefunden wird, statt einen neuen
|
||||
Record anzulegen - ein fehlender
|
||||
Treffer bedeutet meist, dass sich
|
||||
die Fixture-Daten zwischen den
|
||||
Versionen geändert haben.
|
||||
{"$manual": "<ausdruck>"} -> Fehler mit Klartext, da diese
|
||||
Stelle noch von Hand ausgefüllt
|
||||
werden muss.
|
||||
"""
|
||||
if isinstance(value, dict) and "$ref" in value:
|
||||
if value["$ref"] not in created:
|
||||
raise KeyError(
|
||||
f"Referenz '{value['$ref']}' unbekannt - Datensatz mit "
|
||||
f"diesem key wurde noch nicht angelegt oder ist "
|
||||
f"fehlgeschlagen."
|
||||
)
|
||||
return created[value["$ref"]]
|
||||
if isinstance(value, dict) and "$search" in value:
|
||||
spec = value["$search"]
|
||||
cache_key = (spec["model"], json.dumps(spec["domain"], sort_keys=True))
|
||||
if cache_key not in search_cache:
|
||||
matches = Model.get(spec["model"]).find(spec["domain"])
|
||||
if not matches:
|
||||
raise ValueError(
|
||||
f"$search fand keinen Treffer: model={spec['model']!r} "
|
||||
f"domain={spec['domain']!r} - Stammdaten auf diesem "
|
||||
f"Server geändert/entfernt?"
|
||||
)
|
||||
search_cache[cache_key] = matches[0].id
|
||||
return search_cache[cache_key]
|
||||
if isinstance(value, dict) and "$decimal" in value:
|
||||
return decimal.Decimal(value["$decimal"])
|
||||
if isinstance(value, dict) and "$date" in value:
|
||||
return datetime.date.fromisoformat(value["$date"])
|
||||
if isinstance(value, dict) and "$manual" in value:
|
||||
raise ValueError(
|
||||
f"Nicht ausgefüllter $manual-Wert: {value['$manual']!r} - "
|
||||
f"bitte im YAML durch einen echten Wert ersetzen, bevor "
|
||||
f"dieser Datensatz angelegt wird."
|
||||
)
|
||||
if isinstance(value, list):
|
||||
return [resolve_value(v, created, search_cache) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: resolve_value(v, created, search_cache) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def serialize(value):
|
||||
"""Proteus-/Tryton-Werte in eine JSON-taugliche, vergleichbare Form bringen."""
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "id") and hasattr(value, "__class__"):
|
||||
# Verknüpfter Record (Many2One o. ä.) - Modellname + id vergleichen,
|
||||
# nicht das Objekt selbst.
|
||||
return {
|
||||
"__record__": getattr(value, "__class__").__name__,
|
||||
"id": value.id,
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [serialize(v) for v in value]
|
||||
try:
|
||||
json.dumps(value)
|
||||
return value
|
||||
except TypeError:
|
||||
# Decimal, date, datetime, etc. - als String vergleichen
|
||||
return str(value)
|
||||
|
||||
|
||||
def create_records(spec, created, results):
|
||||
fields_cache = {} # model_name -> Set der auf DIESEM Server bekannten Felder
|
||||
search_cache = {} # (model, domain-json) -> id, damit dieselbe Stammdaten-Suche nicht mehrfach läuft
|
||||
|
||||
for entry in spec["records"]:
|
||||
key = entry["key"]
|
||||
model_name = entry["model"]
|
||||
fields = entry.get("fields", {})
|
||||
|
||||
record_result = {
|
||||
"key": key,
|
||||
"model": model_name,
|
||||
"status": "ok",
|
||||
"error": None,
|
||||
"stored": {},
|
||||
}
|
||||
|
||||
try:
|
||||
RecordModel = Model.get(model_name)
|
||||
record = RecordModel()
|
||||
|
||||
# actual_name je YAML-Feldname merken: fürs Zurücklesen unten
|
||||
# brauchen wir denselben (ggf. per Alias aufgelösten) Namen,
|
||||
# im Snapshot aber weiterhin den originalen YAML-Namen als Key.
|
||||
actual_names = {}
|
||||
for field_name, raw_value in fields.items():
|
||||
actual_name = resolve_field_name(RecordModel, model_name, field_name, fields_cache)
|
||||
actual_names[field_name] = actual_name
|
||||
setattr(record, actual_name, resolve_value(raw_value, created, search_cache))
|
||||
|
||||
record.save()
|
||||
created[key] = record.id
|
||||
|
||||
# Bewusst NEU vom Server laden statt das lokale Proteus-Objekt
|
||||
# weiterzuverwenden - das lokale Objekt kann noch den Wert
|
||||
# zeigen, den WIR gesetzt haben, nicht den, den der Server
|
||||
# tatsächlich abgespeichert (und ggf. reformatiert) hat.
|
||||
reloaded = RecordModel(record.id)
|
||||
for field_name in fields:
|
||||
try:
|
||||
record_result["stored"][field_name] = serialize(
|
||||
getattr(reloaded, actual_names[field_name])
|
||||
)
|
||||
except Exception as e:
|
||||
record_result["stored"][field_name] = f"<Lesefehler: {e}>"
|
||||
|
||||
except Exception as e:
|
||||
record_result["status"] = "error"
|
||||
record_result["error"] = f"{type(e).__name__}: {e}"
|
||||
record_result["traceback"] = traceback.format_exc()
|
||||
|
||||
results.append(record_result)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", required=True, help="Pfad zur dataset.yaml/json")
|
||||
parser.add_argument("--database", required=True, help="Name der Tryton-Datenbank")
|
||||
parser.add_argument(
|
||||
"--config-file", default=None,
|
||||
help="Pfad zu trytond.conf, falls nicht der Standardpfad verwendet wird",
|
||||
)
|
||||
parser.add_argument("--output", required=True, help="Wohin der JSON-Snapshot geschrieben wird")
|
||||
args = parser.parse_args()
|
||||
|
||||
# ACHTUNG: set_trytond() ist die lokale Variante (Proteus + trytond im
|
||||
# selben Prozess/Server, kein separater Client-Server-Aufbau). Läuft
|
||||
# euer trytond als eigener Prozess, braucht ihr stattdessen
|
||||
# proteus_config.set_xmlrpc(url) mit einer URL wie
|
||||
# "http://admin:PASSWORT@localhost:8000/mydb_68/".
|
||||
proteus_config.set_trytond(database=args.database, config_file=args.config_file)
|
||||
|
||||
spec = load_spec(args.config)
|
||||
|
||||
created = {}
|
||||
results = []
|
||||
create_records(spec, created, results)
|
||||
|
||||
snapshot = {"database": args.database, "results": results}
|
||||
Path(args.output).write_text(
|
||||
json.dumps(snapshot, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
ok = sum(1 for r in results if r["status"] == "ok")
|
||||
err = sum(1 for r in results if r["status"] == "error")
|
||||
print(f"{ok} Datensätze ok, {err} Fehler -> {args.output}")
|
||||
if err:
|
||||
print("Fehlgeschlagene Datensätze:")
|
||||
for r in results:
|
||||
if r["status"] == "error":
|
||||
print(f" - {r['key']} ({r['model']}): {r['error']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+35005
File diff suppressed because it is too large
Load Diff
+38412
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
# Modellbaum-Vergleich (5.0 ↔ 8.0)
|
||||
|
||||
Werkzeug für die [Testdaten-Pipeline](TESTDATEN.md): Bevor Testdaten von
|
||||
Hand ergänzt werden, erstmal statisch (kein laufendes Tryton nötig)
|
||||
Schwarz auf Weiß sehen, welche Modelle/Felder es in einer Version
|
||||
überhaupt gibt - damit weder Felder aus 8.0 übernommen werden, die es in
|
||||
5.0 noch gar nicht gibt (würde beim Anlegen nur Fehler werfen), noch
|
||||
Aufwand in Modelle gesteckt wird, die sich zwischen den Versionen gar
|
||||
nicht verändert haben.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. `extract_model_tree.py` – Modellbaum aus dem Quellcode ziehen
|
||||
|
||||
Scannt den Python-Code ausgewählter Module per AST (kein Import, keine
|
||||
laufende Instanz) und baut pro Modell (`__name__`) die Liste aller per
|
||||
`fields.X(...)` definierten Felder, mit Typ, Relationsziel
|
||||
(Many2One/One2Many/Many2Many/One2One/Reference) und - falls literal im
|
||||
Code erkennbar - `required`. Modelle werden über PoolMeta-Erweiterungen
|
||||
hinweg zusammengeführt: Fügt z. B. `stock_split` eine Klasse mit
|
||||
`__name__ = 'stock.move'` hinzu, landen deren Felder unter demselben
|
||||
Modell-Eintrag, mit Vermerk, aus welchem Modul sie stammen.
|
||||
|
||||
Pro Modell wird zusätzlich `persisted` erkannt (erbt die Klasse `ModelSQL`
|
||||
o. ä., oder nur `ModelView` - dann ist es ein reines Wizard-/
|
||||
Transient-Formular ohne eigene Tabelle).
|
||||
|
||||
Verfolgt zusätzlich ganz normale **Python-Vererbung innerhalb derselben
|
||||
Datei**: erbt eine Klasse mit eigenem `__name__` von einer anderen lokalen
|
||||
Klasse - z. B. `class BOMOutput(BOMInput): __name__ = 'production.bom.output'`
|
||||
in `production/bom.py`, die selbst keine einzige `fields.X(...)`-Zeile
|
||||
deklariert, sondern alle Felder von `BOMInput` übernimmt - werden die
|
||||
geerbten Felder mit aufgenommen (eigene Felder überschreiben geerbte bei
|
||||
Namensgleichheit). Ohne das sähe ein rein über Vererbung wiederverwendetes
|
||||
Modell wie eines OHNE Felder aus, und Änderungen daran (z. B. eine
|
||||
Feldumbenennung im Elternteil) blieben komplett unsichtbar - genau das hat
|
||||
`sql_dump_diff.py` (Schritt 4) einmal live aufgedeckt.
|
||||
|
||||
Die Basisklassen-Auflösung ist bewusst auf "gleiche Datei" beschränkt
|
||||
(nicht "gleiches Modul"): derselbe lokale Klassenname wie `Cron` taucht
|
||||
innerhalb eines Moduls oft mehrfach für völlig unterschiedliche Klassen
|
||||
auf (Tryton-Konvention: eine PoolMeta-Erweiterung heißt oft genauso wie
|
||||
das Original, z. B. `currency/currency.py` definiert `class Cron(ModelSQL,
|
||||
ModelView): __name__ = 'currency.cron'`, während `currency/ir.py`
|
||||
unabhängig davon eine andere Klasse `class Cron(metaclass=PoolMeta):` zur
|
||||
Erweiterung von `ir.cron` definiert) - ein modulweites Registry würde sich
|
||||
da gegenseitig überschreiben.
|
||||
|
||||
```bash
|
||||
python3 extract_model_tree.py --repo /mnt/tryton \
|
||||
--modules company country currency party product ... \
|
||||
--output reports/model_tree_5.0.json --report reports/model_tree_5.0.md
|
||||
```
|
||||
|
||||
Für eine zweite Version denselben Befehl gegen einen zweiten Checkout
|
||||
laufen lassen (z. B. per `git worktree add /tmp/tryton-8.0 8.0`, ohne den
|
||||
Haupt-Checkout umzuschalten).
|
||||
|
||||
### 2. `model_diff.py` – zwei Modellbäume vergleichen
|
||||
|
||||
Sortiert jedes Modell in eine von fünf Kategorien:
|
||||
|
||||
- **entfernt** – Modell gibt es in Version A, nicht mehr in B
|
||||
- **geändert** – Feld(er) entfernt oder Typ/Relation/required geändert →
|
||||
echter Kandidat für einen Vergleichslauf
|
||||
- **neu** – Modell gibt es erst in B
|
||||
- **nur ergänzt** – nur neue Felder dazugekommen, Rest unverändert
|
||||
- **unverändert** – komplett identisch geblieben
|
||||
|
||||
```bash
|
||||
python3 model_diff.py --tree-a reports/model_tree_5.0.json --label-a 5.0 \
|
||||
--tree-b reports/model_tree_8.0.json --label-b 8.0 \
|
||||
--output reports/model_diff.json --report reports/model_diff.md
|
||||
```
|
||||
|
||||
### 3. `model_coverage.py` – gegen die aktuelle Testdaten-Abdeckung abgleichen
|
||||
|
||||
Vergleicht einen Modellbaum mit einer gemergten `dataset.yaml`
|
||||
(`reports/<version>_testdata_merged.yaml`, siehe
|
||||
[TESTDATEN.md](TESTDATEN.md)) und zeigt komplett unabgedeckte Modelle sowie
|
||||
pro teilweise abgedecktem Modell die ungenutzten Felder (required zuerst).
|
||||
|
||||
Drei Kategorien werden aus der Liste unabgedeckter Modelle per Default
|
||||
herausgefiltert (nie von Hand testdatenrelevant, jeweils per Flag wieder
|
||||
einschaltbar):
|
||||
|
||||
- `ir.*`/`res.*`-Kernmodelle (`--include-framework`)
|
||||
- Wizard-/Transient-Formulare, `persisted: false` (`--include-transient`)
|
||||
- automatisch verwaltete Many2Many-Verknüpfungstabellen, erkennbar am
|
||||
Bindestrich im Namen wie `party.party-party.category`
|
||||
(`--include-relations`)
|
||||
|
||||
Mit `--diff reports/model_diff.json` wird zusätzlich pro Modell die
|
||||
Diff-Kategorie aus Schritt 2 eingeblendet und die Liste danach sortiert:
|
||||
`geändert`/`entfernt` zuerst (lohnt sich, dort Testdaten zu ergänzen),
|
||||
`nur ergänzt`/`unverändert` zuletzt (dort ist über den reinen
|
||||
Codevergleich hinaus erstmal nichts Auffälliges zu erwarten).
|
||||
|
||||
```bash
|
||||
python3 model_coverage.py --model-tree reports/model_tree_5.0.json \
|
||||
--dataset reports/5_testdata_merged.yaml --diff reports/model_diff.json \
|
||||
--report reports/coverage_5.0.md
|
||||
```
|
||||
|
||||
### 4. `sql_dump_diff.py` – gegen den echten DB-Zustand validieren
|
||||
|
||||
Alles bisherige ist reine Code-Analyse. Zum Cross-Check: zwei mit
|
||||
`pg_dump` (Klartext-Format) erzeugte Dumps frisch initialisierter (leerer)
|
||||
Datenbanken beider Versionen strukturell vergleichen - Tabellen
|
||||
entfernt/neu, Spalten hinzugefügt/entfernt/Typ geändert, Zeilenzahl
|
||||
(z. B. mehr XML-Fixture-Daten in der neueren Version). Zeilen werden
|
||||
bereits vollständig geparst (für eine spätere inhaltliche
|
||||
Vergleichs-Erweiterung, sobald es Snapshots mit echten Testdaten gibt),
|
||||
aktuell nutzt der Report davon aber nur die Zeilenzahl.
|
||||
|
||||
Mit `--model-diff reports/model_diff.json` wird pro betroffener Tabelle
|
||||
zusätzlich geprüft, ob die rein statische Code-Analyse (Schritt 2)
|
||||
dieselbe Änderung schon vorhergesagt hatte (Modellname -> Tabellenname:
|
||||
Punkte durch Unterstriche ersetzen, wie `extract_updates.py`/
|
||||
`compare_snapshots.py` es auch tun) - Übereinstimmung bzw. Abweichung wird
|
||||
markiert.
|
||||
|
||||
```bash
|
||||
python3 sql_dump_diff.py --dump-a data/leer5.sql --label-a 5.0 \
|
||||
--dump-b data/leer8.sql --label-b 8.0 \
|
||||
--model-diff reports/model_diff.json \
|
||||
--output reports/dump_diff_5.0_8.0.json --report reports/dump_diff_5.0_8.0.md
|
||||
```
|
||||
|
||||
**Stand:** 78 von 92 vergleichbaren Fällen stimmen überein (~85%). Die
|
||||
verbleibenden Abweichungen gehen meist in eine erwartbare Richtung: die
|
||||
Code-Analyse findet MEHR Veränderung, als im leeren DB-Schema sichtbar
|
||||
ist (z. B. ein entferntes Function-Feld ohne eigene Spalte, oder
|
||||
Selection-Wertänderungen ohne Typänderung) - kein Fehler, sondern zwei
|
||||
Werkzeuge, die unterschiedliche Ebenen messen (Python-Feld-Definition vs.
|
||||
physisches SQL-Schema). Der `production.bom.output`-Fund oben (Schritt 1)
|
||||
kam genau aus einer vorherigen Abweichung dieser Art zustande.
|
||||
|
||||
## Bekannte Grenzen
|
||||
|
||||
- Rein statische Analyse: Felder, die nicht als einfaches
|
||||
`name = fields.X(...)` auf Klassenebene stehen (z. B. per Schleife/Helper
|
||||
dynamisch erzeugt), werden nicht erkannt.
|
||||
- Python-Vererbung wird nur INNERHALB derselben Datei aufgelöst (s. o.).
|
||||
Erbt eine Klasse von einer Basisklasse aus einer anderen Datei desselben
|
||||
oder eines anderen Moduls (Import statt lokaler Definition), werden
|
||||
deren Felder nicht gefunden - unauffällig, aber ein bekannter blinder
|
||||
Fleck.
|
||||
- `persisted`-Erkennung ist eine Textsuche über die Basisklassen-Ausdrücke
|
||||
(`ModelSQL`/`Workflow`/`DeactivableMixin`) - deckt den Normalfall ab, ist
|
||||
aber kein echtes Type-Checking.
|
||||
- Erkennt nur Unterschiede auf Ebene der Feld-**Definition** im Code
|
||||
(Typ/Relation/required). Laufzeitverhalten wie Constraints, Defaults oder
|
||||
DB-Spaltenpräzision kann trotzdem abweichen, ohne hier aufzufallen - dafür
|
||||
bleibt der eigentliche Testlauf über `create_dataset.py`/
|
||||
`compare_snapshots.py` nötig.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Proteus-Testdaten-Pipeline
|
||||
|
||||
Ergänzender Ansatz zur [Codeanalyse](UPDATEANALYSE.md): Statt den
|
||||
`__register__`-Code zu lesen, wird ein deklarativ definierter Testdatensatz
|
||||
über Proteus in zwei frisch installierten Tryton-Instanzen (5.0 und 8.0)
|
||||
tatsächlich angelegt. Ziel ist, dieselbe Datei gegen beide Versionen laufen
|
||||
zu lassen, damit sowohl die Skript-Ausgabe (`compare_snapshots.py`) als
|
||||
auch anschließend die SQL-Dumps beider Datenbanken vergleichbar sind -
|
||||
dafür müssen inhaltlich identische Daten in derselben Reihenfolge
|
||||
eingespielt werden.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
tests/scenario_*.rst (Tryton-Repo, pro Modul)
|
||||
│ rst_to_dataset.py
|
||||
▼
|
||||
rsts/<version>e/*.yaml (ein YAML-Record pro angelegtem Proteus-Objekt)
|
||||
│ rsts_merge_dedup.py
|
||||
▼
|
||||
reports/<version>_testdata_merged.yaml (dedupliziert, abhängigkeitskorrekt sortiert)
|
||||
│ create_dataset.py --database ...
|
||||
▼
|
||||
snapshot_<version>.json
|
||||
│ compare_snapshots.py snapshot_a.json snapshot_b.json
|
||||
▼
|
||||
Fund-Liste: status_changed / value_changed / missing_record
|
||||
```
|
||||
|
||||
### 1. `rst_to_dataset.py` – Testdaten aus den Modul-eigenen Tests extrahieren
|
||||
|
||||
Tryton-Module bringen eigene `tests/scenario_*.rst`-Doctests mit (ausführbarer
|
||||
Proteus-Code in `>>>`-Zeilen). Das Skript parst diese Dateien per AST und
|
||||
erkennt:
|
||||
|
||||
```
|
||||
>>> Party = Model.get('party.party')
|
||||
>>> party = Party(name='Testfirma GmbH')
|
||||
>>> party.vat_number = '123'
|
||||
>>> party.save()
|
||||
```
|
||||
|
||||
→ wird zu einem YAML-Record mit `key`, `model`, `fields`. Referenzen auf
|
||||
zuvor im selben File angelegte Records werden zu `{"$ref": "..."}`,
|
||||
`Decimal(...)`/`date(...)` zu `{"$decimal": ...}`/`{"$date": ...}`.
|
||||
|
||||
Ein weiteres, sehr häufiges Muster wird ebenfalls erkannt: Zugriff auf
|
||||
bereits vorhandene Stammdaten (Länder, Währungen, Einheiten,
|
||||
Standard-Lagerorte, ... - i. d. R. per XML-Fixture beim Modul-Aktivieren
|
||||
angelegt, nicht Teil des eigentlichen Testdatensatzes):
|
||||
|
||||
```
|
||||
>>> ProductUom = Model.get('product.uom')
|
||||
>>> unit, = ProductUom.find([('name', '=', 'Unit')])
|
||||
```
|
||||
|
||||
→ wird zu `{"$search": {"model": "product.uom", "domain": [["name", "=", "Unit"]]}}`
|
||||
- aufgelöst wird das erst live beim Anlegen durch `create_dataset.py`
|
||||
(Schritt 3), nicht schon hier, da diese Records ja nicht selbst Teil der
|
||||
YAML sind. Nur literale Domains werden erkannt (keine Variablen darin);
|
||||
sonst bleibt es wie bisher `$manual`.
|
||||
|
||||
Alles sonst nicht automatisch Auflösbare (Wizards, `.click(...)`,
|
||||
Helper-Funktionsaufrufe) wird als `{"$manual": "<Ausdruck>"}` markiert oder
|
||||
ganz übersprungen - bewusst sichtbar unvollständig statt still falsch.
|
||||
|
||||
Record-Keys werden mit dem Dateinamen präfixiert, damit beim
|
||||
Zusammenführen mehrerer Module keine Kollisionen entstehen.
|
||||
|
||||
### 2. `rsts_merge_dedup.py` – pro Version zusammenführen
|
||||
|
||||
Liest alle `rsts/<version>e/*.yaml` eines Verzeichnisses ein und entfernt
|
||||
Records mit identischem `model`+`fields` (z. B. dasselbe
|
||||
Basis-`product.template`, das in vielen Szenarien wiederverwendet wird).
|
||||
`$ref`-Verweise auf entfernte Duplikate werden auf den behaltenen Record
|
||||
umgeschrieben. Danach wird per Kahn-Algorithmus so sortiert, dass
|
||||
`$ref`-Abhängigkeiten immer VOR dem referenzierenden Record stehen, aber
|
||||
innerhalb der Freiheitsgrade weiterhin nach `model` gruppiert wird.
|
||||
|
||||
**Achtung:** Dedupliziert bewusst GLOBAL, auch innerhalb derselben Datei.
|
||||
Bei Szenarien, die zwei absichtlich gleich aussehende Records anlegen (z. B.
|
||||
`scenario_party_replace.rst`: zwei Parteien namens "Pam", um eine zu
|
||||
ersetzen), verändert das den Sinn des Szenarios - für einen groben
|
||||
Testdatensatz ist das in Ordnung, für szenario-treue Tests nicht.
|
||||
|
||||
### 3. `create_dataset.py` – gegen eine echte Instanz anlegen
|
||||
|
||||
Legt die Records der Reihe nach per Proteus an, liest jeden Wert danach
|
||||
FRISCH VOM SERVER zurück (nicht aus dem lokalen Proteus-Cache), damit
|
||||
automatische Reformatierung sichtbar wird. Ergebnis: ein JSON-Snapshot
|
||||
(`status`/`error`/`stored` pro Record).
|
||||
|
||||
**Feld-Umbenennungen zwischen Versionen** (`FIELD_RENAMES`): Da dieselbe
|
||||
YAML gegen mehrere Versionen laufen soll, aber Felder umbenannt werden
|
||||
können (bestätigt: `stock.move.uom` → `stock.move.unit` zwischen 5.0 und
|
||||
8.0), prüft das Skript pro Modell zuerst per `fields_get()`, ob der
|
||||
YAML-Feldname auf dem jeweiligen Server existiert, und weicht sonst auf
|
||||
einen bekannten, bidirektionalen Alias aus. Ein wirklich unbekanntes Feld
|
||||
bleibt trotzdem ein Fehler - das ist ja gerade das gesuchte Signal für eine
|
||||
echte Schema-Änderung. Weitere Umbenennungen bei Bedarf in `FIELD_RENAMES`
|
||||
ergänzen, sobald ein Lauf sie aufdeckt.
|
||||
|
||||
**`$search`-Auflösung:** Werte der Form `{"$search": {"model": ...,
|
||||
"domain": ...}}` (von `rst_to_dataset.py` erzeugt, siehe Schritt 1) werden
|
||||
live per `Model.get(model).find(domain)` gegen den Zielserver aufgelöst -
|
||||
für Stammdaten, die nicht selbst angelegt werden, sondern schon per
|
||||
XML-Fixture da sind. Ergebnis wird pro Lauf gecacht (dieselbe Suche, z. B.
|
||||
nach der Einheit "Unit", kommt in vielen Records vor). Kein Treffer ->
|
||||
Fehler mit Klartext statt eines neu angelegten Records - das ist selbst
|
||||
ein Signal, dass sich die Stammdaten zwischen den Versionen geändert
|
||||
haben.
|
||||
|
||||
### 4. `compare_snapshots.py` – zwei Versionen gegenüberstellen
|
||||
|
||||
Vergleicht zwei Snapshots (gleiche YAML, zwei Versionen/Datenbanken) und
|
||||
markiert `status_changed` (ein Record schlägt nur in einer Version fehl -
|
||||
Kandidat für Format-/Constraint-/Pflichtfeld-Änderung), `value_changed`
|
||||
(gespeicherter Wert weicht trotz gleichem Input ab - automatische
|
||||
Reformatierung) und `missing_record`.
|
||||
|
||||
## Stand
|
||||
|
||||
- `rst_to_dataset.py` und `rsts_merge_dedup.py` sind gegen echte
|
||||
`scenario_*.rst`-Dateien aus `/mnt/tryton` (5.0- und 8.0-Branch) getestet,
|
||||
siehe `reports/5_testdata_merged.yaml` / `reports/8_testdata_merged.yaml`
|
||||
(jeweils NUR aus den Szenarien der jeweiligen Version extrahiert, noch
|
||||
NICHT zu einer gemeinsamen Datei zusammengeführt).
|
||||
- `create_dataset.py` und `compare_snapshots.py` sind noch nicht gegen eine
|
||||
echte Tryton-Instanz gelaufen (nur syntaktisch geprüft).
|
||||
- Welche Modelle/Felder überhaupt Testdaten brauchen bzw. wo sich 5.0 und
|
||||
8.0 tatsächlich unterscheiden, siehe [MODELLVERGLEICH.md](MODELLVERGLEICH.md)
|
||||
- das steuert, wo als Nächstes von Hand YAML-Records ergänzt werden.
|
||||
@@ -11,18 +11,18 @@ ausführen – ohne die Module tatsächlich zu installieren/upzudaten.
|
||||
```
|
||||
|
||||
Checkt der Reihe nach jeden Versions-Branch (`5.0` bis `8.0`) im
|
||||
Tryton-Repo aus, lässt `extract.py` pro Version über alle konfigurierten
|
||||
Tryton-Repo aus, lässt `extract_updates.py` pro Version über alle konfigurierten
|
||||
Module laufen (Ergebnis: `data/<version>/<modul>.json`), und ruft danach
|
||||
`compare.py` auf, das daraus die aggregierten Reports in `reports/*.md`
|
||||
`compare_updates.py` auf, das daraus die aggregierten Reports in `reports/updateChanges/*.md`
|
||||
erzeugt (ein Report pro Modul, gegliedert nach Tabelle und Version).
|
||||
|
||||
Einzeln aufrufbar:
|
||||
```bash
|
||||
python3 extract.py --repo <pfad-zum-tryton-clone> --output data
|
||||
python3 compare.py
|
||||
python3 extract_updates.py --repo <pfad-zum-tryton-clone> --output data
|
||||
python3 compare_updates.py
|
||||
```
|
||||
|
||||
## Was `extract.py` erkennt
|
||||
## Was `extract_updates.py` erkennt
|
||||
|
||||
- Erkannte DB-Operationen (`column_rename`, `add_column`, `update`,
|
||||
`insert`, `execute`, ... – siehe `INTERESTING_OPS`) inkl. Tabelle(n),
|
||||
@@ -36,7 +36,7 @@ python3 compare.py
|
||||
um bekanntes Rauschen (`Pool()`, `Transaction()`, `cursor`, `.append`,
|
||||
`pool.get`, `logger.*`, `__table__`/`__table_handler__`).
|
||||
|
||||
## Was `extract.py` NICHT erkennt (bewusste Grenzen)
|
||||
## Was `extract_updates.py` NICHT erkennt (bewusste Grenzen)
|
||||
|
||||
- **Cross-Datei-Aufrufe**: Ruft `__register__` eine Funktion aus einem
|
||||
anderen Modul/Utility-Modul auf (z. B. `migrate_property(...)` aus
|
||||
@@ -48,7 +48,7 @@ python3 compare.py
|
||||
- **Implizite Schema-Änderungen** durch reine Feldtyp-Änderungen im
|
||||
Python-Code (ohne expliziten `add_column`/`alter_size`-Aufruf).
|
||||
|
||||
## `compare.py` – Aggregation & Dedup
|
||||
## `compare_updates.py` – Aggregation & Dedup
|
||||
|
||||
Fasst pro Tabelle die Operationen aller Versionen zusammen und zeigt eine
|
||||
Operation nur dann erneut an, wenn sie sich vom **tatsächlichen,
|
||||
@@ -63,17 +63,16 @@ Zeigt keine "wurde entfernt"-Markierung – nur Neues/Verändertes.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
- Modul-/Repo-Defaults: oben in `extract.py` (`DEFAULT_REPO`,
|
||||
- Modul-/Repo-Defaults: oben in `extract_updates.py` (`DEFAULT_REPO`,
|
||||
`DEFAULT_MODULES`).
|
||||
- Versions-Range: `START`/`END` in `run.sh`.
|
||||
- Rauschfilter für `unresolved`: `IGNORED_UNRESOLVED_*` in `extract.py` –
|
||||
- Rauschfilter für `unresolved`: `IGNORED_UNRESOLVED_*` in `extract_updates.py` –
|
||||
bei Bedarf erweitern, wenn sich beim Sichten neue, eindeutig irrelevante
|
||||
Muster zeigen (siehe Git-Historie für die bisherige Herleitung).
|
||||
|
||||
## Nächster Schritt
|
||||
## Verwandter Zweig
|
||||
|
||||
Ergänzender, unabhängiger Zweig: Proteus-basierter Vergleich (echte
|
||||
Ergänzender, unabhängiger Ansatz: Proteus-basierter Vergleich (echte
|
||||
Testdaten in zwei frisch installierten Versionen anlegen, Fehler/
|
||||
Reformatierung erkennen) – siehe `create_dataset.py`,
|
||||
`compare_snapshots.py`, `rst_to_dataset.py` (Stand: ungetestet gegen
|
||||
echte Tryton-Instanz).
|
||||
Reformatierung erkennen) – siehe [doc/TESTDATEN.md](TESTDATEN.md) und
|
||||
[doc/MODELLVERGLEICH.md](MODELLVERGLEICH.md).
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract_model_tree.py
|
||||
|
||||
Scannt den Python-Quellcode ausgewählter Tryton-Module (statisch per AST,
|
||||
keine laufende Instanz nötig) und baut daraus einen Modellbaum: pro Modell
|
||||
(__name__) alle per fields.X(...) definierten Felder, mit Typ,
|
||||
Relationsziel (bei Many2One/One2Many/Many2Many/One2One/Reference) und -
|
||||
falls literal im Code erkennbar - ob das Feld required ist.
|
||||
|
||||
Modelle werden über PoolMeta-Erweiterungen hinweg zusammengeführt: Fügt
|
||||
z. B. stock_split eine Klasse mit __name__ = 'stock.move' hinzu, landen
|
||||
deren Felder unter demselben Modell-Eintrag, mit Vermerk, aus welchem
|
||||
Modul sie stammen.
|
||||
|
||||
Zusätzlich wird ganz normale Python-Vererbung innerhalb eines Moduls
|
||||
verfolgt: erbt eine Klasse mit eigenem __name__ von einer anderen lokalen
|
||||
Klasse (z. B. `class BOMOutput(BOMInput): __name__ = 'production.bom.output'`
|
||||
in production/bom.py, die selbst keine einzige fields.X(...)-Zeile
|
||||
deklariert, sondern alle Felder von BOMInput übernimmt), werden die
|
||||
geerbten Felder mit aufgenommen. Ohne das sähe ein rein über Vererbung
|
||||
wiederverwendetes Modell wie ein Modell OHNE Felder aus - und Änderungen
|
||||
daran (z. B. eine Feldumbenennung im Elternteil) blieben unsichtbar.
|
||||
Löst nur Basisklassen auf, die im selben Modul per einfachem Namen (kein
|
||||
Attribut-Zugriff auf ein anderes Modul) gefunden werden - externe Basen
|
||||
wie ModelSQL/ModelView/Workflow tragen ohnehin keine fields.X(...) bei.
|
||||
|
||||
Zweck hier: BEVOR versucht wird, dieselbe dataset.yaml gegen 5.0 und 8.0
|
||||
laufen zu lassen, erstmal Schwarz auf Weiß sehen, welche Modelle/Felder in
|
||||
der 5.0-Codebasis (bei den tatsächlich aktiven Modulen) überhaupt
|
||||
existieren - damit weder Modelle/Felder aus 8.0 übernommen werden, die es
|
||||
in 5.0 noch gar nicht gibt (das würde nur Fehler werfen), noch Modelle im
|
||||
Datensatz fehlen, für die aktuell kein *.rst-Testszenario existiert.
|
||||
|
||||
Nutzung:
|
||||
python3 extract_model_tree.py --repo /mnt/tryton \\
|
||||
--modules company country currency party product \\
|
||||
product_measurements production production_split \\
|
||||
stock stock_lot stock_product_location \\
|
||||
stock_shipment_measurements stock_split \\
|
||||
--output reports/model_tree_5.0.json --report reports/model_tree_5.0.md
|
||||
|
||||
Ohne --modules wird DEFAULT_MODULES (unten) verwendet.
|
||||
|
||||
WICHTIG: Rein statische Analyse. Felder, die nicht als einfaches
|
||||
"name = fields.X(...)" auf Klassenebene stehen (z. B. per Schleife/
|
||||
Helper-Funktion dynamisch erzeugt), werden nicht erkannt - kommt in der
|
||||
Praxis selten vor, aber nicht ausgeschlossen.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_REPO = "/mnt/tryton"
|
||||
|
||||
DEFAULT_MODULES = [
|
||||
"ir",
|
||||
"res",
|
||||
"company",
|
||||
"country",
|
||||
"currency",
|
||||
"party",
|
||||
"product",
|
||||
"product_measurements",
|
||||
"production",
|
||||
"production_split",
|
||||
"stock",
|
||||
"stock_lot",
|
||||
"stock_lot_sled",
|
||||
"stock_product_location",
|
||||
"stock_shipment_measurements",
|
||||
"stock_split",
|
||||
]
|
||||
|
||||
RELATIONAL_TYPES = {"Many2One", "One2Many", "Many2Many", "One2One", "Reference"}
|
||||
|
||||
|
||||
def find_name_assignment(class_body):
|
||||
"""Sucht `__name__ = '...'` direkt im Klassenkörper."""
|
||||
for stmt in class_body:
|
||||
if (
|
||||
isinstance(stmt, ast.Assign)
|
||||
and len(stmt.targets) == 1
|
||||
and isinstance(stmt.targets[0], ast.Name)
|
||||
and stmt.targets[0].id == "__name__"
|
||||
and isinstance(stmt.value, ast.Constant)
|
||||
and isinstance(stmt.value.value, str)
|
||||
):
|
||||
return stmt.value.value
|
||||
return None
|
||||
|
||||
|
||||
def fields_call_info(call):
|
||||
"""Wenn `call` ein fields.<Typ>(...)-Aufruf ist, gibt (Typ, Relation,
|
||||
required, is_function, has_setter) zurück, sonst None."""
|
||||
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute):
|
||||
return None
|
||||
if not isinstance(call.func.value, ast.Name) or call.func.value.id != "fields":
|
||||
return None
|
||||
field_type = call.func.attr
|
||||
|
||||
relation = None
|
||||
if field_type in RELATIONAL_TYPES and call.args:
|
||||
first = call.args[0]
|
||||
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
||||
relation = first.value
|
||||
else:
|
||||
relation = "<dynamisch>"
|
||||
|
||||
is_function = field_type == "Function"
|
||||
has_setter = False
|
||||
if is_function:
|
||||
# fields.Function(fields.Char(...), 'getter', setter='setter_name')
|
||||
if len(call.args) >= 1:
|
||||
inner = fields_call_info(call.args[0])
|
||||
if inner:
|
||||
field_type = f"Function[{inner[0]}]"
|
||||
has_setter = any(kw.arg == "setter" for kw in call.keywords)
|
||||
|
||||
required = None
|
||||
for kw in call.keywords:
|
||||
if kw.arg == "required" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, bool):
|
||||
required = kw.value.value
|
||||
|
||||
return field_type, relation, required, is_function, has_setter
|
||||
|
||||
|
||||
def is_persisted_class(node):
|
||||
"""True, wenn die Klasse ModelSQL (direkt oder z. B. via Workflow, das
|
||||
selbst von ModelSQL erbt - daher genügt eine Textsuche über alle
|
||||
Basisklassen-Ausdrücke) als Basis hat - Unterscheidungsmerkmal
|
||||
zwischen echten, persistenten Modellen und reinen Wizard-/
|
||||
Transient-Formularen (die nur ModelView erben)."""
|
||||
for base in node.bases:
|
||||
try:
|
||||
text = ast.unparse(base)
|
||||
except Exception:
|
||||
continue
|
||||
if "ModelSQL" in text or "Workflow" in text or "DeactivableMixin" in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def own_fields_of(node, module_name, module_dir, path):
|
||||
"""Nur die in DIESEM Klassenkörper direkt deklarierten fields.X(...)."""
|
||||
fields = {}
|
||||
for stmt in node.body:
|
||||
if not (
|
||||
isinstance(stmt, ast.Assign)
|
||||
and len(stmt.targets) == 1
|
||||
and isinstance(stmt.targets[0], ast.Name)
|
||||
):
|
||||
continue
|
||||
info = fields_call_info(stmt.value)
|
||||
if info is None:
|
||||
continue
|
||||
field_type, relation, required, is_function, has_setter = info
|
||||
fields[stmt.targets[0].id] = {
|
||||
"type": field_type,
|
||||
"relation": relation,
|
||||
"required": required,
|
||||
"settable": (not is_function) or has_setter,
|
||||
"module": module_name,
|
||||
"file": f"{module_name}/{path.relative_to(module_dir)}",
|
||||
}
|
||||
return fields
|
||||
|
||||
|
||||
def base_class_names(node):
|
||||
"""Best-effort Namen der Basisklassen als String, nur einfache
|
||||
`class X(Y):`-Fälle (kein Attribut-Zugriff wie `other.Y`, das wäre
|
||||
ohnehin eine externe/andere Modul-Klasse, die wir hier nicht kennen)."""
|
||||
return [base.id for base in node.bases if isinstance(base, ast.Name)]
|
||||
|
||||
|
||||
def collect_classes(path, module_name, module_dir, registry):
|
||||
"""Erste Durchsicht: registriert jede Klasse (lokaler Python-Name,
|
||||
Basisklassen, eigene Felder, ggf. Tryton-__name__, ggf. direkt
|
||||
persistent) - unabhängig davon, ob/wie sie später gebraucht wird.
|
||||
Die eigentliche Feld-/Persisted-Auflösung inkl. Vererbung passiert
|
||||
danach in resolve_class_fields()/resolve_class_persisted().
|
||||
|
||||
Registry-Key ist (module_name, Dateipfad), NICHT nur module_name:
|
||||
derselbe lokale Klassenname (z. B. "Cron") kommt innerhalb eines
|
||||
Moduls in mehreren Dateien für kompott unterschiedliche Klassen vor
|
||||
(Tryton-Konvention: eine PoolMeta-Erweiterung heißt oft genauso wie
|
||||
das Original) - ein modulweites Registry würde sich da gegenseitig
|
||||
überschreiben. Basisklassen-Auflösung bleibt dadurch bewusst auf
|
||||
"gleiche Datei" beschränkt, was dem tatsächlichen Python-Scoping in
|
||||
der weit überwiegenden Mehrheit der Fälle entspricht."""
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (SyntaxError, UnicodeDecodeError) as e:
|
||||
print(f" WARNUNG: {path} übersprungen ({e})", file=sys.stderr)
|
||||
return
|
||||
|
||||
file_key = (module_name, str(path))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
registry.setdefault(file_key, {})[node.name] = {
|
||||
"bases": base_class_names(node),
|
||||
"own_fields": own_fields_of(node, module_name, module_dir, path),
|
||||
"model_name": find_name_assignment(node.body),
|
||||
"persisted_here": is_persisted_class(node),
|
||||
}
|
||||
|
||||
|
||||
def resolve_class_fields(scope_key, class_name, registry, cache, visiting):
|
||||
"""Felder einer lokalen Klasse inkl. per Basisklasse(n) in derselben
|
||||
Datei (scope_key = (module_name, Dateipfad)) geerbter Felder (eigene
|
||||
Felder überschreiben geerbte bei Namensgleichheit, wie bei echter
|
||||
Python-Attributauflösung)."""
|
||||
key = (scope_key, class_name)
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
if key in visiting:
|
||||
return {} # zyklische Vererbung - sollte nicht vorkommen, Notbremse
|
||||
class_info = registry.get(scope_key, {}).get(class_name)
|
||||
if class_info is None:
|
||||
return {} # externe/unbekannte Basisklasse (ModelSQL, Workflow, ...)
|
||||
|
||||
visiting.add(key)
|
||||
fields = {}
|
||||
for base_name in class_info["bases"]:
|
||||
fields.update(resolve_class_fields(scope_key, base_name, registry, cache, visiting))
|
||||
fields.update(class_info["own_fields"])
|
||||
visiting.discard(key)
|
||||
|
||||
cache[key] = fields
|
||||
return fields
|
||||
|
||||
|
||||
def resolve_class_persisted(scope_key, class_name, registry, cache, visiting):
|
||||
"""True, wenn diese Klasse selbst oder eine ihrer lokalen Basisklassen
|
||||
ModelSQL/Workflow/DeactivableMixin als Basis hat."""
|
||||
key = (scope_key, class_name)
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
if key in visiting:
|
||||
return False
|
||||
class_info = registry.get(scope_key, {}).get(class_name)
|
||||
if class_info is None:
|
||||
return False
|
||||
|
||||
visiting.add(key)
|
||||
result = class_info["persisted_here"] or any(
|
||||
resolve_class_persisted(scope_key, base_name, registry, cache, visiting)
|
||||
for base_name in class_info["bases"]
|
||||
)
|
||||
visiting.discard(key)
|
||||
|
||||
cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def module_directory(repo, module_name):
|
||||
"""ir/res liegen als Kernbestandteil in trytond/trytond/, nicht unter
|
||||
modules/ wie die regulären (nachinstallierbaren) Module."""
|
||||
if module_name in ("ir", "res"):
|
||||
return Path(repo) / "trytond" / "trytond" / module_name
|
||||
return Path(repo) / "modules" / module_name
|
||||
|
||||
|
||||
def scan_modules(repo, modules):
|
||||
# Phase 1: alle Klassen roh einsammeln (Basen, eigene Felder, __name__).
|
||||
registry = {}
|
||||
for module_name in modules:
|
||||
module_dir = module_directory(repo, module_name)
|
||||
if not module_dir.is_dir():
|
||||
print(f"WARNUNG: Modul '{module_name}' nicht gefunden unter {module_dir}", file=sys.stderr)
|
||||
continue
|
||||
for path in sorted(module_dir.rglob("*.py")):
|
||||
if "tests" in path.relative_to(module_dir).parts:
|
||||
continue
|
||||
collect_classes(path, module_name, module_dir, registry)
|
||||
|
||||
# Phase 2: pro Klasse mit __name__ die Felder (inkl. lokaler Python-
|
||||
# Vererbung) auflösen und unter dem Tryton-Modellnamen zusammenführen -
|
||||
# mehrere Klassen mit demselben __name__ (PoolMeta-Erweiterungen aus
|
||||
# anderen Modulen) tragen weiterhin gemeinsam zum selben Eintrag bei.
|
||||
fields_cache, persisted_cache = {}, {}
|
||||
models = {}
|
||||
persisted_models = set()
|
||||
for scope_key, classes in registry.items():
|
||||
for class_name, info in classes.items():
|
||||
if not info["model_name"]:
|
||||
continue
|
||||
resolved = resolve_class_fields(scope_key, class_name, registry, fields_cache, set())
|
||||
models.setdefault(info["model_name"], {}).update(resolved)
|
||||
if resolve_class_persisted(scope_key, class_name, registry, persisted_cache, set()):
|
||||
persisted_models.add(info["model_name"])
|
||||
|
||||
return {
|
||||
model_name: {"persisted": model_name in persisted_models, "fields": fields}
|
||||
for model_name, fields in models.items()
|
||||
}
|
||||
|
||||
|
||||
def write_report(models, out_path, modules):
|
||||
lines = [f"# Modellbaum ({', '.join(modules)})\n"]
|
||||
for model_name in sorted(models):
|
||||
fields = models[model_name]["fields"]
|
||||
if not fields:
|
||||
continue
|
||||
contributing_modules = sorted({f["module"] for f in fields.values()})
|
||||
transient_note = "" if models[model_name]["persisted"] else " _(Wizard/Transient - nicht persistent)_"
|
||||
lines.append(f"\n## `{model_name}`{transient_note} _(Felder aus: {', '.join(contributing_modules)})_\n")
|
||||
for field_name in sorted(fields):
|
||||
f = fields[field_name]
|
||||
parts = [f["type"]]
|
||||
if f["relation"]:
|
||||
parts.append(f"-> `{f['relation']}`")
|
||||
if f["required"] is True:
|
||||
parts.append("required")
|
||||
if not f["settable"]:
|
||||
parts.append("NICHT setzbar (Function ohne setter)")
|
||||
if f["module"] != model_name.split(".")[0]:
|
||||
parts.append(f"(aus Modul `{f['module']}`)")
|
||||
lines.append(f"- **{field_name}**: {' '.join(parts)}")
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--repo", default=DEFAULT_REPO, help="Pfad zum Tryton-Repo-Checkout")
|
||||
parser.add_argument("--modules", nargs="+", default=DEFAULT_MODULES, help="Zu scannende Modulnamen")
|
||||
parser.add_argument("--output", default="reports/model_tree.json", help="JSON-Ausgabe (maschinenlesbar)")
|
||||
parser.add_argument("--report", default="reports/model_tree.md", help="Markdown-Report (menschenlesbar)")
|
||||
args = parser.parse_args()
|
||||
|
||||
models = scan_modules(args.repo, args.modules)
|
||||
|
||||
Path(args.output).write_text(json.dumps(models, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
||||
write_report(models, Path(args.report), args.modules)
|
||||
|
||||
total_fields = sum(len(m["fields"]) for m in models.values())
|
||||
print(f"{len(models)} Modelle, {total_fields} Felder -> {args.output}, {args.report}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
model_coverage.py
|
||||
|
||||
Vergleicht den per extract_model_tree.py ermittelten Modellbaum (was gibt
|
||||
es in der 5.0-Codebasis wirklich an Modellen/Feldern) mit dem aktuellen
|
||||
Stand einer aus *.rst-Szenarien gemergten dataset.yaml (was wird davon
|
||||
schon durch Testdaten abgedeckt).
|
||||
|
||||
Zeigt:
|
||||
- Modelle, die es in 5.0 gibt, aber für die noch KEIN Record in der
|
||||
dataset.yaml existiert (komplett unabgedeckt).
|
||||
- Pro abgedecktem Modell: setzbare Felder (keine reinen Function-Felder
|
||||
ohne setter), die von keinem Record verwendet werden - required
|
||||
zuerst, da dort eher etwas fehlt, das für's Anlegen nötig wäre.
|
||||
|
||||
Für die Liste der komplett unabgedeckten Modelle werden per Default drei
|
||||
Kategorien ausgeblendet, die man ohnehin nie händisch mit Testdaten
|
||||
befüllt (jeweils per Flag reaktivierbar):
|
||||
- ir.*/res.*-Kernmodelle (--include-framework)
|
||||
- Wizard-/Transient-Formulare, die nur ModelView statt ModelSQL erben,
|
||||
laut extract_model_tree.py also "persisted": false (--include-transient)
|
||||
- automatisch verwaltete Many2Many-Verknüpfungstabellen, erkennbar am
|
||||
Bindestrich im Modellnamen wie "party.party-party.category"
|
||||
(--include-relations)
|
||||
|
||||
Mit --diff (Ausgabe von model_diff.py, z. B. model_diff.json aus einem
|
||||
5.0->8.0-Vergleich) wird zusätzlich pro Modell vermerkt, ob es sich
|
||||
zwischen den Versionen überhaupt geändert hat, und danach sortiert:
|
||||
"geändert"/"entfernt" zuerst (echte Kandidaten für einen Vergleichslauf),
|
||||
"nur ergänzt"/"unverändert" zuletzt (dort ist über den Codevergleich
|
||||
hinaus erstmal nichts Auffälliges zu erwarten).
|
||||
|
||||
Nutzung:
|
||||
python3 model_coverage.py --model-tree reports/model_tree_5.0.json \\
|
||||
--dataset reports/5_testdata_merged.yaml --diff reports/model_diff.json \\
|
||||
--report reports/coverage_5.0.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
|
||||
def load_records(path):
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
if yaml is not None:
|
||||
return yaml.safe_load(text)["records"]
|
||||
return json.loads(text)["records"]
|
||||
|
||||
|
||||
def used_fields_by_model(records):
|
||||
used = {}
|
||||
for r in records:
|
||||
used.setdefault(r["model"], set()).update(r["fields"].keys())
|
||||
return used
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--model-tree", required=True, help="JSON von extract_model_tree.py")
|
||||
parser.add_argument("--dataset", required=True, help="Gemergte dataset.yaml/json")
|
||||
parser.add_argument("--report", required=True, help="Ziel-Markdown-Datei")
|
||||
parser.add_argument(
|
||||
"--include-framework", action="store_true",
|
||||
help="ir.*/res.*-Kernmodelle in der Liste unabgedeckter Modelle NICHT herausfiltern",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-transient", action="store_true",
|
||||
help="Wizard-/Transient-Formulare (persisted=false) NICHT herausfiltern",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-relations", action="store_true",
|
||||
help="Many2Many-Verknüpfungstabellen (Bindestrich im Namen) NICHT herausfiltern",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diff", default=None,
|
||||
help="Optional: model_diff.json - blendet die Diff-Kategorie ein und "
|
||||
"sortiert 'geändert'/'entfernt' nach oben",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
tree = json.loads(Path(args.model_tree).read_text(encoding="utf-8"))
|
||||
records = load_records(args.dataset)
|
||||
used = used_fields_by_model(records)
|
||||
|
||||
diff = json.loads(Path(args.diff).read_text(encoding="utf-8")) if args.diff else {}
|
||||
PRIORITY = {"geändert": 0, "entfernt": 1, "neu": 2, "nur ergänzt": 3, "unverändert": 4}
|
||||
|
||||
def priority(model_name):
|
||||
if not diff:
|
||||
return 0 # ohne --diff: keine Umsortierung, alphabetische Reihenfolge bleibt
|
||||
category = diff.get(model_name, {}).get("category")
|
||||
return PRIORITY.get(category, 5)
|
||||
|
||||
def diff_note(model_name):
|
||||
if not diff:
|
||||
return ""
|
||||
category = diff.get(model_name, {}).get("category", "nicht in beiden Bäumen")
|
||||
return f" _[{category}]_"
|
||||
|
||||
uncovered_models = []
|
||||
partially_covered = []
|
||||
skipped = {"framework": 0, "transient": 0, "relation": 0}
|
||||
|
||||
for model_name in sorted(tree):
|
||||
entry = tree[model_name]
|
||||
fields = entry["fields"]
|
||||
settable = {name for name, info in fields.items() if info["settable"]}
|
||||
if not settable:
|
||||
continue # z.B. reine Log-/History-Modelle ohne setzbare Felder
|
||||
|
||||
if model_name not in used:
|
||||
if not args.include_framework and (
|
||||
model_name.startswith("ir.") or model_name.startswith("res.")
|
||||
):
|
||||
skipped["framework"] += 1
|
||||
continue
|
||||
if not args.include_transient and not entry["persisted"]:
|
||||
skipped["transient"] += 1
|
||||
continue
|
||||
if not args.include_relations and "-" in model_name:
|
||||
skipped["relation"] += 1
|
||||
continue
|
||||
uncovered_models.append(model_name)
|
||||
continue
|
||||
|
||||
missing = settable - used[model_name]
|
||||
if missing:
|
||||
missing_required = sorted(f for f in missing if fields[f]["required"] is True)
|
||||
missing_optional = sorted(f for f in missing if fields[f]["required"] is not True)
|
||||
partially_covered.append((model_name, missing_required, missing_optional))
|
||||
|
||||
uncovered_models.sort(key=priority)
|
||||
partially_covered.sort(key=lambda item: priority(item[0]))
|
||||
|
||||
lines = ["# Testdaten-Abdeckung gegen den 5.0-Modellbaum\n"]
|
||||
|
||||
lines.append(f"\n## Komplett unabgedeckte Modelle ({len(uncovered_models)})\n")
|
||||
lines.append("Für diese Modelle gibt es noch gar keinen Record in der dataset.yaml:\n")
|
||||
for m in uncovered_models:
|
||||
lines.append(f"- `{m}`{diff_note(m)}")
|
||||
|
||||
lines.append(f"\n\n## Teilweise abgedeckte Modelle ({len(partially_covered)})\n")
|
||||
lines.append("Modell hat mind. einen Record, aber nicht alle setzbaren Felder werden benutzt:\n")
|
||||
for model_name, missing_required, missing_optional in partially_covered:
|
||||
lines.append(f"\n### `{model_name}`{diff_note(model_name)}")
|
||||
if missing_required:
|
||||
lines.append(f"- **required, aber ungenutzt:** {', '.join(missing_required)}")
|
||||
if missing_optional:
|
||||
lines.append(f"- optional, ungenutzt: {', '.join(missing_optional)}")
|
||||
|
||||
Path(args.report).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
skip_parts = []
|
||||
if skipped["framework"]:
|
||||
skip_parts.append(f"{skipped['framework']} ir.*/res.*")
|
||||
if skipped["transient"]:
|
||||
skip_parts.append(f"{skipped['transient']} Wizard/Transient")
|
||||
if skipped["relation"]:
|
||||
skip_parts.append(f"{skipped['relation']} Verknüpfungstabellen")
|
||||
note = f" ({', '.join(skip_parts)} ausgeblendet)" if skip_parts else ""
|
||||
|
||||
print(
|
||||
f"{len(uncovered_models)} Modelle komplett unabgedeckt{note}, "
|
||||
f"{len(partially_covered)} teilweise abgedeckt -> {args.report}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
model_diff.py
|
||||
|
||||
Vergleicht zwei mit extract_model_tree.py erzeugte Modellbäume (z. B.
|
||||
model_tree_5.0.json gegen model_tree_8.0.json) auf Modell- und Feldebene
|
||||
und sortiert jedes Modell in eine von vier Kategorien:
|
||||
|
||||
- entfernt: Modell gibt es in der ersten Version, aber nicht mehr
|
||||
in der zweiten.
|
||||
- neu: Modell gibt es erst in der zweiten Version.
|
||||
- geändert: Modell existiert in beiden, aber mind. ein Feld wurde
|
||||
entfernt oder hat sich (Typ/Relation/required)
|
||||
geändert. Das sind die Kandidaten, bei denen sich ein
|
||||
echter Testlauf gegen beide Versionen lohnt.
|
||||
- unverändert / nur ergänzt: Modell ist identisch geblieben oder hat nur
|
||||
zusätzliche Felder bekommen - hier ist über den reinen
|
||||
Code-Vergleich hinaus erstmal nichts Auffälliges zu
|
||||
erwarten (Laufzeitverhalten wie Constraints/Defaults
|
||||
kann natürlich trotzdem abweichen, das sieht man mit
|
||||
dieser rein statischen Analyse nicht).
|
||||
|
||||
Nutzung:
|
||||
python3 model_diff.py --tree-a reports/model_tree_5.0.json --label-a 5.0 \\
|
||||
--tree-b reports/model_tree_8.0.json --label-b 8.0 \\
|
||||
--output reports/model_diff.json --report reports/model_diff.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def diff_field(fa, fb):
|
||||
changes = []
|
||||
if fa["type"] != fb["type"]:
|
||||
changes.append(f"type: {fa['type']} -> {fb['type']}")
|
||||
if fa["relation"] != fb["relation"]:
|
||||
changes.append(f"relation: {fa['relation']} -> {fb['relation']}")
|
||||
if fa["required"] != fb["required"]:
|
||||
changes.append(f"required: {fa['required']} -> {fb['required']}")
|
||||
return changes
|
||||
|
||||
|
||||
def diff_trees(tree_a, tree_b):
|
||||
result = {}
|
||||
for model_name in sorted(set(tree_a) | set(tree_b)):
|
||||
if model_name not in tree_b:
|
||||
result[model_name] = {"category": "entfernt", "added": [], "removed": [], "changed": {}}
|
||||
continue
|
||||
if model_name not in tree_a:
|
||||
result[model_name] = {"category": "neu", "added": [], "removed": [], "changed": {}}
|
||||
continue
|
||||
|
||||
fields_a = tree_a[model_name]["fields"]
|
||||
fields_b = tree_b[model_name]["fields"]
|
||||
added = sorted(set(fields_b) - set(fields_a))
|
||||
removed = sorted(set(fields_a) - set(fields_b))
|
||||
changed = {}
|
||||
for name in sorted(set(fields_a) & set(fields_b)):
|
||||
c = diff_field(fields_a[name], fields_b[name])
|
||||
if c:
|
||||
changed[name] = c
|
||||
|
||||
if removed or changed:
|
||||
category = "geändert"
|
||||
elif added:
|
||||
category = "nur ergänzt"
|
||||
else:
|
||||
category = "unverändert"
|
||||
|
||||
result[model_name] = {
|
||||
"category": category,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"changed": changed,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def write_report(diff, out_path, label_a, label_b):
|
||||
by_category = {}
|
||||
for model_name, info in diff.items():
|
||||
by_category.setdefault(info["category"], []).append(model_name)
|
||||
|
||||
lines = [f"# Modellbaum-Diff: {label_a} -> {label_b}\n"]
|
||||
for category, count_note in [
|
||||
("entfernt", "Modell existiert nicht mehr"),
|
||||
("geändert", "Feld(er) entfernt oder geändert - Kandidat für einen echten Vergleichslauf"),
|
||||
("neu", f"Modell gibt es erst ab {label_b}"),
|
||||
("nur ergänzt", "nur neue Felder dazugekommen, Rest unverändert"),
|
||||
("unverändert", "komplett identisch geblieben"),
|
||||
]:
|
||||
models = sorted(by_category.get(category, []))
|
||||
lines.append(f"\n## {category} ({len(models)}) - {count_note}\n")
|
||||
for model_name in models:
|
||||
info = diff[model_name]
|
||||
if category == "geändert":
|
||||
lines.append(f"\n### `{model_name}`")
|
||||
if info["removed"]:
|
||||
lines.append(f"- entfernte Felder: {', '.join(info['removed'])}")
|
||||
for field_name, changes in info["changed"].items():
|
||||
lines.append(f"- **{field_name}**: {'; '.join(changes)}")
|
||||
if info["added"]:
|
||||
lines.append(f"- (zusätzlich neue Felder: {', '.join(info['added'])})")
|
||||
else:
|
||||
lines.append(f"- `{model_name}`")
|
||||
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--tree-a", required=True)
|
||||
parser.add_argument("--tree-b", required=True)
|
||||
parser.add_argument("--label-a", default="A")
|
||||
parser.add_argument("--label-b", default="B")
|
||||
parser.add_argument("--output", default="reports/model_diff.json")
|
||||
parser.add_argument("--report", default="reports/model_diff.md")
|
||||
args = parser.parse_args()
|
||||
|
||||
tree_a = json.loads(Path(args.tree_a).read_text(encoding="utf-8"))
|
||||
tree_b = json.loads(Path(args.tree_b).read_text(encoding="utf-8"))
|
||||
|
||||
diff = diff_trees(tree_a, tree_b)
|
||||
|
||||
Path(args.output).write_text(json.dumps(diff, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
||||
write_report(diff, Path(args.report), args.label_a, args.label_b)
|
||||
|
||||
counts = {}
|
||||
for info in diff.values():
|
||||
counts[info["category"]] = counts.get(info["category"], 0) + 1
|
||||
summary = ", ".join(f"{v} {k}" for k, v in counts.items())
|
||||
print(f"{summary} -> {args.output}, {args.report}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,775 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"key": "scenario_party_erase__party",
|
||||
"model": "party.party",
|
||||
"fields": {
|
||||
"name": "Pam",
|
||||
"active": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_party_erase__attachment",
|
||||
"model": "ir.attachment",
|
||||
"fields": {
|
||||
"resource": {
|
||||
"$ref": "scenario_party_erase__party"
|
||||
},
|
||||
"name": "Attachment"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_party_replace__party1",
|
||||
"model": "party.party",
|
||||
"fields": {
|
||||
"name": "Pam"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_lot_shipment_out__customer",
|
||||
"model": "party.party",
|
||||
"fields": {
|
||||
"name": "Customer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_product_location__supplier",
|
||||
"model": "party.party",
|
||||
"fields": {
|
||||
"name": "Suppier"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_reporting__supplier",
|
||||
"model": "party.party",
|
||||
"fields": {
|
||||
"name": "Supplier"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_internal__employee_party",
|
||||
"model": "party.party",
|
||||
"fields": {
|
||||
"name": "Employee"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_internal__employee",
|
||||
"model": "company.employee",
|
||||
"fields": {
|
||||
"party": {
|
||||
"$ref": "scenario_stock_shipment_internal__employee_party"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_out__employee",
|
||||
"model": "company.employee",
|
||||
"fields": {
|
||||
"party": {
|
||||
"$ref": "scenario_stock_shipment_internal__employee_party"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_production__template",
|
||||
"model": "product.template",
|
||||
"fields": {
|
||||
"name": "product",
|
||||
"default_uom": {
|
||||
"$search": {
|
||||
"model": "product.uom",
|
||||
"domain": [
|
||||
[
|
||||
"name",
|
||||
"=",
|
||||
"Unit"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "goods",
|
||||
"producible": true,
|
||||
"list_price": {
|
||||
"$decimal": "30"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_production__template1",
|
||||
"model": "product.template",
|
||||
"fields": {
|
||||
"name": "component 1",
|
||||
"default_uom": {
|
||||
"$search": {
|
||||
"model": "product.uom",
|
||||
"domain": [
|
||||
[
|
||||
"name",
|
||||
"=",
|
||||
"Unit"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "goods",
|
||||
"list_price": {
|
||||
"$decimal": "5"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_production__template2",
|
||||
"model": "product.template",
|
||||
"fields": {
|
||||
"name": "component 2",
|
||||
"default_uom": {
|
||||
"$search": {
|
||||
"model": "product.uom",
|
||||
"domain": [
|
||||
[
|
||||
"name",
|
||||
"=",
|
||||
"Meter"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "goods",
|
||||
"list_price": {
|
||||
"$decimal": "7"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_average_cost_price__template",
|
||||
"model": "product.template",
|
||||
"fields": {
|
||||
"name": "Product",
|
||||
"default_uom": {
|
||||
"$search": {
|
||||
"model": "product.uom",
|
||||
"domain": [
|
||||
[
|
||||
"name",
|
||||
"=",
|
||||
"Unit"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "goods",
|
||||
"list_price": {
|
||||
"$decimal": "300"
|
||||
},
|
||||
"cost_price_method": "average"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_inventory__template2",
|
||||
"model": "product.template",
|
||||
"fields": {
|
||||
"name": "Product",
|
||||
"default_uom": {
|
||||
"$search": {
|
||||
"model": "product.uom",
|
||||
"domain": [
|
||||
[
|
||||
"name",
|
||||
"=",
|
||||
"Kilogram"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "goods",
|
||||
"list_price": {
|
||||
"$decimal": "140"
|
||||
},
|
||||
"cost_price_method": "average"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_lot_shipment_out__template",
|
||||
"model": "product.template",
|
||||
"fields": {
|
||||
"name": "Product",
|
||||
"default_uom": {
|
||||
"$search": {
|
||||
"model": "product.uom",
|
||||
"domain": [
|
||||
[
|
||||
"name",
|
||||
"=",
|
||||
"Unit"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "goods",
|
||||
"list_price": {
|
||||
"$decimal": "20"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_production__bom",
|
||||
"model": "production.bom",
|
||||
"fields": {
|
||||
"name": "product"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_production__production",
|
||||
"model": "production",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"product": {
|
||||
"$manual": "product"
|
||||
},
|
||||
"bom": {
|
||||
"$ref": "scenario_production__bom"
|
||||
},
|
||||
"quantity": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_production__production_lead_time",
|
||||
"model": "production.lead_time",
|
||||
"fields": {
|
||||
"product": {
|
||||
"$manual": "product"
|
||||
},
|
||||
"bom": {
|
||||
"$ref": "scenario_production__bom"
|
||||
},
|
||||
"lead_time": {
|
||||
"$manual": "datetime.timedelta(1)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_internal__stock_user",
|
||||
"model": "res.user",
|
||||
"fields": {
|
||||
"name": "Stock",
|
||||
"login": "stock",
|
||||
"main_company": {
|
||||
"$manual": "company"
|
||||
},
|
||||
"employee": {
|
||||
"$ref": "scenario_stock_shipment_internal__employee"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_out__stock_user",
|
||||
"model": "res.user",
|
||||
"fields": {
|
||||
"name": "Stock",
|
||||
"login": "stock",
|
||||
"main_company": {
|
||||
"$manual": "company"
|
||||
},
|
||||
"employee": {
|
||||
"$ref": "scenario_stock_shipment_out__employee"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_inventory__inventory",
|
||||
"model": "stock.inventory",
|
||||
"fields": {
|
||||
"location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"empty_quantity": "keep"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_inventory_empty_quantity__inventory",
|
||||
"model": "stock.inventory",
|
||||
"fields": {
|
||||
"location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"empty_quantity": "empty"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_average_cost_price__storage_sub_loc",
|
||||
"model": "stock.location",
|
||||
"fields": {
|
||||
"name": "Storage Sub",
|
||||
"type": "storage",
|
||||
"parent": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_product_location__child_loc",
|
||||
"model": "stock.location",
|
||||
"fields": {
|
||||
"name": "Child Location",
|
||||
"parent": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"code": "CHI"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_in_same_storage_input__storage1",
|
||||
"model": "stock.location",
|
||||
"fields": {
|
||||
"name": "Storage 1",
|
||||
"parent": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_internal__internal_loc",
|
||||
"model": "stock.location",
|
||||
"fields": {
|
||||
"name": "Internal",
|
||||
"type": "storage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_internal__lead_time",
|
||||
"model": "stock.location.lead_time",
|
||||
"fields": {
|
||||
"from_warehouse": {
|
||||
"$manual": "storage_loc.warehouse"
|
||||
},
|
||||
"to_warehouse": {
|
||||
"$manual": "storage_loc.warehouse"
|
||||
},
|
||||
"lead_time": {
|
||||
"$manual": "datetime.timedelta(1)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_average_cost_price__incoming_move",
|
||||
"model": "stock.move",
|
||||
"fields": {
|
||||
"product": {
|
||||
"$manual": "product"
|
||||
},
|
||||
"uom": {
|
||||
"$search": {
|
||||
"model": "product.uom",
|
||||
"domain": [
|
||||
[
|
||||
"name",
|
||||
"=",
|
||||
"Unit"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"quantity": 1,
|
||||
"from_location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"SUP"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"to_location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"effective_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
},
|
||||
"unit_price": {
|
||||
"$decimal": "200"
|
||||
},
|
||||
"currency": {
|
||||
"$manual": "company.currency"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_product_location__shipment_in",
|
||||
"model": "stock.shipment.in",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"supplier": {
|
||||
"$ref": "scenario_stock_product_location__supplier"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_reporting__shipment_in",
|
||||
"model": "stock.shipment.in",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"supplier": {
|
||||
"$ref": "scenario_stock_reporting__supplier"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_in_same_storage_input__shipment_in",
|
||||
"model": "stock.shipment.in",
|
||||
"fields": {
|
||||
"supplier": {
|
||||
"$ref": "scenario_stock_reporting__supplier"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_reporting__shipment_internal",
|
||||
"model": "stock.shipment.internal",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
},
|
||||
"from_location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"to_location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"type",
|
||||
"=",
|
||||
"lost_found"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_internal__shipment",
|
||||
"model": "stock.shipment.internal",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"from_location": {
|
||||
"$ref": "scenario_stock_shipment_internal__internal_loc"
|
||||
},
|
||||
"to_location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"STO"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_lot_shipment_out__shipment_out",
|
||||
"model": "stock.shipment.out",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"customer": {
|
||||
"$ref": "scenario_stock_lot_shipment_out__customer"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_reporting__shipment_out",
|
||||
"model": "stock.shipment.out",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"customer": {
|
||||
"$ref": "scenario_stock_lot_shipment_out__customer"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_out__shipment_out",
|
||||
"model": "stock.shipment.out",
|
||||
"fields": {
|
||||
"planned_date": {
|
||||
"$manual": "today"
|
||||
},
|
||||
"customer": {
|
||||
"$ref": "scenario_stock_lot_shipment_out__customer"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_out_same_storage_output__shipment_out",
|
||||
"model": "stock.shipment.out",
|
||||
"fields": {
|
||||
"customer": {
|
||||
"$ref": "scenario_stock_lot_shipment_out__customer"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_split_shipment__shipment1",
|
||||
"model": "stock.shipment.out",
|
||||
"fields": {
|
||||
"customer": {
|
||||
"$ref": "scenario_stock_lot_shipment_out__customer"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_product_location__shipment_out_return",
|
||||
"model": "stock.shipment.out.return",
|
||||
"fields": {
|
||||
"customer": {
|
||||
"$ref": "scenario_stock_lot_shipment_out__customer"
|
||||
},
|
||||
"supplier": {
|
||||
"$ref": "scenario_stock_product_location__supplier"
|
||||
},
|
||||
"from_location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"OUT"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"to_location": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"SUP"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "scenario_stock_shipment_out_return_same_storage_input__shipment",
|
||||
"model": "stock.shipment.out.return",
|
||||
"fields": {
|
||||
"customer": {
|
||||
"$ref": "scenario_stock_lot_shipment_out__customer"
|
||||
},
|
||||
"warehouse": {
|
||||
"$search": {
|
||||
"model": "stock.location",
|
||||
"domain": [
|
||||
[
|
||||
"code",
|
||||
"=",
|
||||
"WH"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"company": {
|
||||
"$manual": "company"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
# Testdaten-Abdeckung gegen den 5.0-Modellbaum
|
||||
|
||||
|
||||
## Komplett unabgedeckte Modelle (36)
|
||||
|
||||
Für diese Modelle gibt es noch gar keinen Record in der dataset.yaml:
|
||||
|
||||
- `company.company` _[geändert]_
|
||||
- `country.subdivision` _[geändert]_
|
||||
- `currency.currency` _[geändert]_
|
||||
- `currency.currency.rate` _[geändert]_
|
||||
- `party.address` _[geändert]_
|
||||
- `party.address.format` _[geändert]_
|
||||
- `party.contact_mechanism` _[geändert]_
|
||||
- `product.cost_price` _[geändert]_
|
||||
- `product.list_price` _[geändert]_
|
||||
- `product.product` _[geändert]_
|
||||
- `production.bom.input` _[geändert]_
|
||||
- `production.bom.output` _[geändert]_
|
||||
- `stock.inventory.line` _[geändert]_
|
||||
- `stock.product.location` _[geändert]_
|
||||
- `country.zip` _[entfernt]_
|
||||
- `stock.lot.type` _[entfernt]_
|
||||
- `country.country` _[nur ergänzt]_
|
||||
- `party.configuration` _[nur ergänzt]_
|
||||
- `party.identifier` _[nur ergänzt]_
|
||||
- `product.category` _[nur ergänzt]_
|
||||
- `product.configuration` _[nur ergänzt]_
|
||||
- `production.configuration` _[nur ergänzt]_
|
||||
- `stock.configuration` _[nur ergänzt]_
|
||||
- `stock.lot` _[nur ergänzt]_
|
||||
- `stock.shipment.in.return` _[nur ergänzt]_
|
||||
- `party.category` _[unverändert]_
|
||||
- `party.party.lang` _[unverändert]_
|
||||
- `party.party.location` _[unverändert]_
|
||||
- `product.cost_price_method` _[unverändert]_
|
||||
- `product.uom` _[unverändert]_
|
||||
- `product.uom.category` _[unverändert]_
|
||||
- `production.configuration.production_sequence` _[unverändert]_
|
||||
- `stock.configuration.sequence` _[unverändert]_
|
||||
- `stock.period` _[unverändert]_
|
||||
- `stock.period.cache` _[unverändert]_
|
||||
- `stock.period.cache.lot` _[unverändert]_
|
||||
|
||||
|
||||
## Teilweise abgedeckte Modelle (15)
|
||||
|
||||
Modell hat mind. einen Record, aber nicht alle setzbaren Felder werden benutzt:
|
||||
|
||||
|
||||
### `product.template` _[geändert]_
|
||||
- optional, ungenutzt: categories, categories_all, consumable, cost_price_methods, expiration_state, expiration_time, height, height_uom, length, length_uom, list_prices, lot_required, products, shelf_life_state, shelf_life_time, volume, volume_uom, weight, weight_uom, width, width_uom
|
||||
|
||||
### `production` _[geändert]_
|
||||
- **required, aber ungenutzt:** company, location, warehouse
|
||||
- optional, ungenutzt: effective_date, effective_start_date, inputs, number, outputs, planned_start_date, reference, state, uom
|
||||
|
||||
### `res.user` _[geändert]_
|
||||
- **required, aber ungenutzt:** menu
|
||||
- optional, ungenutzt: actions, applications, company, email, employees, groups, language, password, password_hash, password_reset, password_reset_expire, signature, warnings
|
||||
|
||||
### `stock.inventory` _[geändert]_
|
||||
- **required, aber ungenutzt:** company, date, lost_found
|
||||
- optional, ungenutzt: lines, number, state
|
||||
|
||||
### `stock.move` _[geändert]_
|
||||
- **required, aber ungenutzt:** internal_quantity
|
||||
- optional, ungenutzt: cost_price, internal_volume, internal_weight, lot, origin, production_input, production_output, shipment, state
|
||||
|
||||
### `stock.shipment.in` _[geändert]_
|
||||
- optional, ungenutzt: contact_address, effective_date, incoming_moves, inventory_moves, moves, number, reference, state
|
||||
|
||||
### `stock.shipment.out` _[geändert]_
|
||||
- **required, aber ungenutzt:** delivery_address
|
||||
- optional, ungenutzt: effective_date, inventory_moves, moves, number, outgoing_moves, reference, state
|
||||
|
||||
### `stock.shipment.out.return` _[geändert]_
|
||||
- **required, aber ungenutzt:** delivery_address
|
||||
- optional, ungenutzt: effective_date, incoming_moves, inventory_moves, moves, number, planned_date, reference, state
|
||||
|
||||
### `company.employee` _[nur ergänzt]_
|
||||
- **required, aber ungenutzt:** company
|
||||
- optional, ungenutzt: end_date, start_date
|
||||
|
||||
### `party.party` _[nur ergänzt]_
|
||||
- **required, aber ungenutzt:** code
|
||||
- optional, ungenutzt: addresses, categories, contact_mechanisms, customer_location, identifiers, lang, langs, locations, replaced_by, supplier_location
|
||||
|
||||
### `production.bom` _[nur ergänzt]_
|
||||
- optional, ungenutzt: inputs, output_products, outputs
|
||||
|
||||
### `stock.location` _[nur ergänzt]_
|
||||
- **required, aber ungenutzt:** left, right
|
||||
- optional, ungenutzt: address, childs, flat_childs, input_location, output_location, picking_location, production_location, storage_location
|
||||
|
||||
### `stock.shipment.internal` _[nur ergänzt]_
|
||||
- optional, ungenutzt: effective_date, effective_start_date, incoming_moves, moves, number, outgoing_moves, planned_start_date, reference, state
|
||||
|
||||
### `ir.attachment` _[unverändert]_
|
||||
- **required, aber ungenutzt:** type
|
||||
- optional, ungenutzt: data, description, file_id, link
|
||||
|
||||
### `stock.location.lead_time` _[unverändert]_
|
||||
- optional, ungenutzt: warehouse_from, warehouse_to
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
# SQL-Dump-Diff: 5.0 -> 8.0
|
||||
|
||||
|
||||
## Tabellen entfernt (4)
|
||||
|
||||
- `country_zip` _(`country.zip`=entfernt [übereinstimmung])_
|
||||
- `cron_company_rel` _(kein passendes Modell in model_diff gefunden)_
|
||||
- `party_category_rel` _(kein passendes Modell in model_diff gefunden)_
|
||||
- `stock_lot_type` _(`stock.lot.type`=entfernt [übereinstimmung])_
|
||||
|
||||
|
||||
## Tabellen neu (31)
|
||||
|
||||
- `company_company_logo_cache` _(`company.company.logo.cache`=neu [übereinstimmung])_
|
||||
- `company_company_tax_identifier` _(`company.company.tax_identifier`=neu [übereinstimmung])_
|
||||
- `country_organization` _(`country.organization`=neu [übereinstimmung])_
|
||||
- `country_organization_member` _(`country.organization.member`=neu [übereinstimmung])_
|
||||
- `country_postal_code` _(`country.postal_code`=neu [übereinstimmung])_
|
||||
- `country_region` _(`country.region`=neu [übereinstimmung])_
|
||||
- `currency_cron` _(`currency.cron`=neu [übereinstimmung])_
|
||||
- `ir_avatar` _(`ir.avatar`=neu [übereinstimmung])_
|
||||
- `ir_avatar_cache` _(`ir.avatar.cache`=neu [übereinstimmung])_
|
||||
- `ir_calendar_day` _(`ir.calendar.day`=neu [übereinstimmung])_
|
||||
- `ir_calendar_month` _(`ir.calendar.month`=neu [übereinstimmung])_
|
||||
- `ir_chat_channel` _(`ir.chat.channel`=neu [übereinstimmung])_
|
||||
- `ir_chat_follower` _(`ir.chat.follower`=neu [übereinstimmung])_
|
||||
- `ir_chat_message` _(`ir.chat.message`=neu [übereinstimmung])_
|
||||
- `ir_cron_log` _(`ir.cron.log`=neu [übereinstimmung])_
|
||||
- `ir_email` _(`ir.email`=neu [übereinstimmung])_
|
||||
- `ir_email_address` _(`ir.email.address`=neu [übereinstimmung])_
|
||||
- `ir_email_template` _(`ir.email.template`=neu [übereinstimmung])_
|
||||
- `ir_error` _(`ir.error`=neu [übereinstimmung])_
|
||||
- `ir_filestore_queue` _(`ir.filestore.queue`=neu [übereinstimmung])_
|
||||
- `ir_message` _(`ir.message`=neu [übereinstimmung])_
|
||||
- `ir_model_log` _(`ir.model.log`=neu [übereinstimmung])_
|
||||
- `ir_ui_view_tree_optional` _(`ir.ui.view_tree_optional`=neu [übereinstimmung])_
|
||||
- `party_address_subdivision_type` _(`party.address.subdivision_type`=neu [übereinstimmung])_
|
||||
- `party_contact_mechanism_language` _(`party.contact_mechanism.language`=neu [übereinstimmung])_
|
||||
- `product_configuration_default_lot_sequence` _(`product.configuration.default_lot_sequence`=neu [übereinstimmung])_
|
||||
- `product_cost_price_revision` _(`product.cost_price.revision`=neu [übereinstimmung])_
|
||||
- `product_identifier` _(`product.identifier`=neu [übereinstimmung])_
|
||||
- `res_notification` _(`res.notification`=neu [übereinstimmung])_
|
||||
- `res_user_device` _(`res.user.device`=neu [übereinstimmung])_
|
||||
- `stock_location_waste` _(`stock.location.waste`=neu [übereinstimmung])_
|
||||
|
||||
|
||||
## Tabellen mit Schema-Änderung (59)
|
||||
|
||||
Spalten wurden hinzugefügt/entfernt oder haben einen anderen Typ - vergleichbar mit einer model_diff.py-Kategorie.
|
||||
|
||||
|
||||
### `company_company` _(`company.company`=geändert [übereinstimmung])_
|
||||
- neue Spalten: logo
|
||||
- entfernte Spalten: parent
|
||||
|
||||
### `company_employee` _(`company.employee`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: supervisor
|
||||
|
||||
### `country_country` _(`country.country`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: active, region
|
||||
|
||||
### `country_subdivision` _(`country.subdivision`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: active
|
||||
- Zeilenzahl: 4835 -> 5046
|
||||
|
||||
### `ir_action` _(`ir.action`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: records
|
||||
- Zeilenzahl: 122 -> 167
|
||||
|
||||
### `ir_action_report` _(`ir.action.report`=geändert [übereinstimmung])_
|
||||
- neue Spalten: record_name
|
||||
- entfernte Spalten: email
|
||||
|
||||
### `ir_action_wizard` _(`ir.action.wizard`=geändert [übereinstimmung])_
|
||||
- entfernte Spalten: email
|
||||
- Zeilenzahl: 25 -> 32
|
||||
|
||||
### `ir_attachment` _(`ir.attachment`=unverändert [ABWEICHUNG])_
|
||||
- neue Spalten: copy_to_resources
|
||||
|
||||
### `ir_cache` _(`ir.cache`=unverändert [ABWEICHUNG])_
|
||||
- **create_date**: timestamp(6) without time zone -> timestamp without time zone
|
||||
- **write_date**: timestamp(6) without time zone -> timestamp without time zone
|
||||
- Zeilenzahl: 21 -> 43
|
||||
|
||||
### `ir_configuration` _(`ir.configuration`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: product_price_decimal, series
|
||||
|
||||
### `ir_cron` _(`ir.cron`=geändert [übereinstimmung])_
|
||||
- neue Spalten: day, hour, method, minute, weekday
|
||||
- entfernte Spalten: args, function, model, name, number_calls, repeat_missed, request_user, user
|
||||
- Zeilenzahl: 1 -> 10
|
||||
|
||||
### `ir_export` _(`ir.export`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: header, ignore_search_limit, records, user
|
||||
|
||||
### `ir_lang` _(`ir.lang`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: am, nplurals, pg_text_search, plural, pm
|
||||
- Zeilenzahl: 19 -> 25
|
||||
|
||||
### `ir_model` _(`ir.model`=geändert [übereinstimmung])_
|
||||
- neue Spalten: string
|
||||
- entfernte Spalten: model
|
||||
- Zeilenzahl: 159 -> 215
|
||||
|
||||
### `ir_model_access` _(`ir.model.access`=geändert [übereinstimmung])_
|
||||
- neue Spalten: active
|
||||
- **model**: integer -> character varying
|
||||
- Zeilenzahl: 138 -> 163
|
||||
|
||||
### `ir_model_button` _(`ir.model.button`=geändert [übereinstimmung])_
|
||||
- neue Spalten: active
|
||||
- **model**: integer -> character varying
|
||||
- Zeilenzahl: 67 -> 80
|
||||
|
||||
### `ir_model_data` _(`ir.model.data`=geändert [übereinstimmung])_
|
||||
- neue Spalten: field_names
|
||||
- entfernte Spalten: fs_values, values
|
||||
- Zeilenzahl: 6583 -> 2080
|
||||
|
||||
### `ir_model_field` _(`ir.model.field`=geändert [übereinstimmung])_
|
||||
- neue Spalten: access, string
|
||||
- entfernte Spalten: field_description
|
||||
- **model**: integer -> character varying
|
||||
- Zeilenzahl: 1709 -> 2858
|
||||
|
||||
### `ir_model_field_access` _(`ir.model.field.access`=geändert [übereinstimmung])_
|
||||
- neue Spalten: active, model
|
||||
- **field**: integer -> character varying
|
||||
|
||||
### `ir_note` _(`ir.note`=unverändert [ABWEICHUNG])_
|
||||
- neue Spalten: copy_to_resources
|
||||
|
||||
### `ir_queue` _(`ir.queue`=unverändert [ABWEICHUNG])_
|
||||
- **data**: text -> jsonb
|
||||
|
||||
### `ir_rule_group` _(`ir.rule.group`=geändert [übereinstimmung])_
|
||||
- **model**: integer -> character varying
|
||||
- Zeilenzahl: 26 -> 34
|
||||
|
||||
### `ir_sequence` _(`ir.sequence`=geändert [übereinstimmung])_
|
||||
- neue Spalten: sequence_type
|
||||
- entfernte Spalten: code
|
||||
|
||||
### `ir_sequence_strict` _(`ir.sequence.strict`=geändert [übereinstimmung])_
|
||||
- neue Spalten: sequence_type
|
||||
- entfernte Spalten: code
|
||||
|
||||
### `ir_sequence_type` _(`ir.sequence.type`=geändert [übereinstimmung])_
|
||||
- entfernte Spalten: code
|
||||
- Zeilenzahl: 8 -> 13
|
||||
|
||||
### `ir_session` _(`ir.session`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: ip_address
|
||||
|
||||
### `ir_translation` _(`ir.translation`=geändert [übereinstimmung])_
|
||||
- neue Spalten: src_plural, value_1, value_2, value_3
|
||||
- entfernte Spalten: src_md5
|
||||
- Zeilenzahl: 8173 -> 8940
|
||||
|
||||
### `ir_trigger` _(`ir.trigger`=geändert [übereinstimmung])_
|
||||
- neue Spalten: action, on_create_, on_delete_, on_time_, on_write_
|
||||
- entfernte Spalten: action_function, action_model, on_create, on_delete, on_time, on_write
|
||||
|
||||
### `ir_ui_menu_favorite` _(`ir.ui.menu.favorite`=unverändert [ABWEICHUNG])_
|
||||
- entfernte Spalten: sequence
|
||||
|
||||
### `ir_ui_view_tree_width` _(`ir.ui.view_tree_width`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: occurrence, screen_width
|
||||
|
||||
### `party_address` _(`party.address`=geändert [übereinstimmung])_
|
||||
- neue Spalten: attn, building_name, building_number, floor_number, post_box, post_office, postal_code, private_bag, room_number, street_name, street_unstructured, unit_number
|
||||
- entfernte Spalten: name, street, zip
|
||||
|
||||
### `party_address_format` _(`party.address.format`=geändert [übereinstimmung])_
|
||||
- neue Spalten: building_number_format, country_code, floor_number_format, language_code, post_box_format, post_office_format, private_bag_format, room_number_format, street_format, unit_number_format
|
||||
- entfernte Spalten: country, language
|
||||
- Zeilenzahl: 66 -> 222
|
||||
|
||||
### `party_configuration` _(`party.configuration`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: identifier_types
|
||||
|
||||
### `party_contact_mechanism` _(`party.contact_mechanism`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: address, delivery
|
||||
|
||||
### `party_identifier` _(`party.identifier`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: active, address, code_compact
|
||||
|
||||
### `party_party` _(`party.party`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: code_alnum, code_digit
|
||||
|
||||
### `product_category` _(`product.category`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: code
|
||||
|
||||
### `product_configuration` _(`product.configuration`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: category_sequence, product_sequence, template_sequence
|
||||
|
||||
### `product_list_price` _(`product.list_price`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: product
|
||||
|
||||
### `product_product` _(`product.product`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: position, replaced_by, suffix_code
|
||||
|
||||
### `product_template` _(`product.template`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: code, lot_required, lot_sequence
|
||||
|
||||
### `production` _(`production`=geändert [übereinstimmung])_
|
||||
- neue Spalten: assigned_by, done_by, origin, origin_planned_date, run_by, type, unit
|
||||
- entfernte Spalten: uom
|
||||
|
||||
### `production_bom` _(`production.bom`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: code, phantom, phantom_quantity, phantom_unit
|
||||
|
||||
### `production_bom_input` _(`production.bom.input`=geändert [übereinstimmung])_
|
||||
- neue Spalten: phantom_bom, unit
|
||||
- entfernte Spalten: uom
|
||||
|
||||
### `production_bom_output` _(`production.bom.output`=geändert [übereinstimmung])_
|
||||
- neue Spalten: phantom_bom, unit
|
||||
- entfernte Spalten: uom
|
||||
|
||||
### `production_configuration` _(`production.configuration`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: bom_sequence
|
||||
|
||||
### `res_group` _(`res.group`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: active, parent
|
||||
- Zeilenzahl: 10 -> 13
|
||||
|
||||
### `res_user` _(`res.user`=geändert [übereinstimmung])_
|
||||
- neue Spalten: company_filter, notifications, warehouse
|
||||
- entfernte Spalten: main_company
|
||||
- Zeilenzahl: 3 -> 2
|
||||
|
||||
### `res_user_login_attempt` _(`res.user.login.attempt`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: device_cookie
|
||||
|
||||
### `stock_inventory` _(`stock.inventory`=geändert [übereinstimmung])_
|
||||
- entfernte Spalten: lost_found
|
||||
|
||||
### `stock_location` _(`stock.location`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: allow_pickup, lost_found_location, production_output_location, production_picking_location
|
||||
|
||||
### `stock_lot` _(`stock.lot`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: active
|
||||
|
||||
### `stock_move` _(`stock.move`=geändert [übereinstimmung])_
|
||||
- neue Spalten: origin_planned_date, product_cost_price, production_cost_price_updated, unit, unit_price_updated
|
||||
- entfernte Spalten: internal_volume, internal_weight, uom
|
||||
|
||||
### `stock_product_location` _(`stock.product.location`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: template
|
||||
|
||||
### `stock_shipment_in` _(`stock.shipment.in`=geändert [ABWEICHUNG])_
|
||||
- neue Spalten: origin_planned_date, warehouse_input, warehouse_storage
|
||||
|
||||
### `stock_shipment_in_return` _(`stock.shipment.in.return`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: origin_planned_date
|
||||
|
||||
### `stock_shipment_internal` _(`stock.shipment.internal`=nur ergänzt [übereinstimmung])_
|
||||
- neue Spalten: internal_transit_location, origin_planned_date, packed_by
|
||||
|
||||
### `stock_shipment_out` _(`stock.shipment.out`=geändert [übereinstimmung])_
|
||||
- neue Spalten: origin_planned_date, picked_by, shipped_by, warehouse_output, warehouse_storage
|
||||
- entfernte Spalten: assigned_by
|
||||
|
||||
### `stock_shipment_out_return` _(`stock.shipment.out.return`=geändert [übereinstimmung])_
|
||||
- neue Spalten: contact_address, origin_planned_date, warehouse_input, warehouse_storage
|
||||
- entfernte Spalten: delivery_address
|
||||
|
||||
|
||||
## Nur Zeilenzahl unterschiedlich, Schema identisch (15)
|
||||
|
||||
Keine Code-Definitionsänderung zu erwarten (z. B. mehr XML-Fixture-Daten in der neueren Version) - daher kein model_diff-Abgleich.
|
||||
|
||||
- `currency_currency`: 170 -> 178
|
||||
- `currency_currency_rate`: 1 -> 0
|
||||
- `ir_action_act_window`: 87 -> 125
|
||||
- `ir_action_act_window_domain`: 39 -> 47
|
||||
- `ir_action_act_window_view`: 146 -> 225
|
||||
- `ir_action_keyword`: 117 -> 165
|
||||
- `ir_module`: 138 -> 219
|
||||
- `ir_module_config_wizard_item`: 3 -> 4
|
||||
- `ir_module_dependency`: 587 -> 951
|
||||
- `ir_rule`: 34 -> 38
|
||||
- `ir_ui_icon`: 16 -> 20
|
||||
- `ir_ui_menu`: 93 -> 108
|
||||
- `ir_ui_view`: 261 -> 367
|
||||
- `product_uom`: 32 -> 35
|
||||
- `product_uom_category`: 6 -> 7
|
||||
|
||||
|
||||
## Unverändert (30)
|
||||
|
||||
`ir_action_url`, `ir_export_line`, `ir_model_button_click`, `ir_model_button_rule`, `ir_note_read`, `ir_session_wizard`, `ir_trigger_log`, `ir_ui_view_search`, `ir_ui_view_tree_state`, `party_category`, `party_configuration_party_lang`, `party_configuration_party_sequence`, `party_party_lang`, `party_party_location`, `product_configuration_default_cost_price_method`, `product_cost_price`, `product_cost_price_method`, `production_configuration_production_sequence`, `production_lead_time`, `res_user_application`, `res_user_warning`, `stock_configuration`, `stock_configuration_location`, `stock_configuration_sequence`, `stock_inventory_line`, `stock_location_lead_time`, `stock_location_lot_shelf_life`, `stock_period`, `stock_period_cache`, `stock_period_cache_lot`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,458 @@
|
||||
# Modellbaum-Diff: 5.0 -> 8.0
|
||||
|
||||
|
||||
## entfernt (12) - Modell existiert nicht mehr
|
||||
|
||||
- `country.zip`
|
||||
- `ir.model.field-res.group`
|
||||
- `product.template-stock.lot.type`
|
||||
- `production.assign`
|
||||
- `production.assign.failed`
|
||||
- `stock.lot.type`
|
||||
- `stock.shipment.in.return.assign`
|
||||
- `stock.shipment.in.return.assign.failed`
|
||||
- `stock.shipment.internal.assign`
|
||||
- `stock.shipment.internal.assign.failed`
|
||||
- `stock.shipment.out.assign`
|
||||
- `stock.shipment.out.assign.failed`
|
||||
|
||||
## geändert (55) - Feld(er) entfernt oder geändert - Kandidat für einen echten Vergleichslauf
|
||||
|
||||
|
||||
### `company.company`
|
||||
- entfernte Felder: childs, parent
|
||||
- (zusätzlich neue Felder: logo, logo_cache, tax_identifiers)
|
||||
|
||||
### `country.subdivision`
|
||||
- **code**: required: True -> None
|
||||
- **type**: required: True -> None
|
||||
- (zusätzlich neue Felder: flag)
|
||||
|
||||
### `currency.currency`
|
||||
- **symbol**: required: True -> None
|
||||
|
||||
### `currency.currency.rate`
|
||||
- **currency**: required: None -> True
|
||||
- **rate**: required: None -> True
|
||||
|
||||
### `ir.action`
|
||||
- **type**: type: Char -> Selection
|
||||
- (zusätzlich neue Felder: action, records)
|
||||
|
||||
### `ir.action.act_window`
|
||||
- **domains**: type: Function[Binary] -> Function[Field]
|
||||
- **views**: type: Function[Binary] -> Function[Field]
|
||||
|
||||
### `ir.action.act_window.domain`
|
||||
- entfernte Felder: sequence
|
||||
|
||||
### `ir.action.act_window.view`
|
||||
- entfernte Felder: sequence
|
||||
- **act_window**: required: None -> True
|
||||
- (zusätzlich neue Felder: model)
|
||||
|
||||
### `ir.action.keyword`
|
||||
- **action**: required: None -> True
|
||||
- **groups**: type: Function[One2Many] -> Function[Many2Many]
|
||||
|
||||
### `ir.action.report`
|
||||
- entfernte Felder: email, pyson_email
|
||||
- (zusätzlich neue Felder: record_name, report_content_html)
|
||||
|
||||
### `ir.action.wizard`
|
||||
- entfernte Felder: email
|
||||
|
||||
### `ir.cron`
|
||||
- entfernte Felder: args, function, model, name, number_calls, repeat_missed, request_user, user
|
||||
- **interval_type**: required: None -> True
|
||||
- **next_call**: required: True -> None
|
||||
- (zusätzlich neue Felder: day, hour, logs, method, minute, running, timezone, weekday)
|
||||
|
||||
### `ir.model`
|
||||
- entfernte Felder: info, model
|
||||
- **fields**: required: True -> None
|
||||
- **name**: required: None -> True
|
||||
- (zusätzlich neue Felder: string)
|
||||
|
||||
### `ir.model.access`
|
||||
- **model**: type: Many2One -> Char; relation: ir.model -> None
|
||||
|
||||
### `ir.model.button`
|
||||
- **model**: type: Many2One -> Char; relation: ir.model -> None
|
||||
|
||||
### `ir.model.data`
|
||||
- entfernte Felder: fs_values, out_of_sync, values
|
||||
- **db_id**: required: True -> None
|
||||
- (zusätzlich neue Felder: field_names)
|
||||
|
||||
### `ir.model.field`
|
||||
- entfernte Felder: field_description, groups
|
||||
- **model**: type: Many2One -> Char; relation: ir.model -> None
|
||||
- (zusätzlich neue Felder: access, string)
|
||||
|
||||
### `ir.model.field.access`
|
||||
- **field**: type: Many2One -> Char; relation: ir.model.field -> None
|
||||
- (zusätzlich neue Felder: model)
|
||||
|
||||
### `ir.module`
|
||||
- **childs**: type: Function[One2Many] -> Function[Many2Many]
|
||||
- **parents**: type: Function[One2Many] -> Function[Many2Many]
|
||||
|
||||
### `ir.module.config_wizard.item`
|
||||
- entfernte Felder: sequence
|
||||
|
||||
### `ir.rule.group`
|
||||
- **model**: type: Many2One -> Char; relation: ir.model -> None
|
||||
- **name**: required: None -> True
|
||||
|
||||
### `ir.sequence`
|
||||
- entfernte Felder: code, groups
|
||||
- (zusätzlich neue Felder: preview, sequence_type)
|
||||
|
||||
### `ir.sequence.strict`
|
||||
- entfernte Felder: code, groups
|
||||
- (zusätzlich neue Felder: preview, sequence_type)
|
||||
|
||||
### `ir.sequence.type`
|
||||
- entfernte Felder: code
|
||||
|
||||
### `ir.translation`
|
||||
- entfernte Felder: src_md5
|
||||
- (zusätzlich neue Felder: field, src_plural, value_1, value_2, value_3)
|
||||
|
||||
### `ir.trigger`
|
||||
- entfernte Felder: action_function, action_model, on_create, on_delete, on_time, on_write
|
||||
- (zusätzlich neue Felder: action, on_create_, on_delete_, on_time_, on_write_)
|
||||
|
||||
### `ir.ui.icon`
|
||||
- entfernte Felder: sequence
|
||||
- **icon**: type: Function[Char] -> Function[Text]
|
||||
|
||||
### `ir.ui.view`
|
||||
- entfernte Felder: priority
|
||||
- (zusätzlich neue Felder: basis, extensions)
|
||||
|
||||
### `ir.ui.view_search`
|
||||
- **user**: required: True -> None
|
||||
|
||||
### `ir.ui.view_tree_width`
|
||||
- **width**: required: True -> None
|
||||
- (zusätzlich neue Felder: occurrence, screen_width)
|
||||
|
||||
### `party.address`
|
||||
- entfernte Felder: name, zip
|
||||
- **street**: type: Text -> Function[Text]
|
||||
- (zusätzlich neue Felder: attn, building_name, building_number, contact_mechanisms, floor_number, identifiers, post_box, post_office, postal_code, private_bag, room_number, street_name, street_single_line, street_unstructured, subdivision_types, unit_number, warehouses)
|
||||
|
||||
### `party.address.format`
|
||||
- entfernte Felder: country, language
|
||||
- (zusätzlich neue Felder: building_number_format, country_code, floor_number_format, language_code, post_box_format, post_office_format, private_bag_format, room_number_format, street_format, unit_number_format)
|
||||
|
||||
### `party.contact_mechanism`
|
||||
- **value**: required: None -> True
|
||||
- (zusätzlich neue Felder: address, delivery, language, languages)
|
||||
|
||||
### `product.cost_price`
|
||||
- **cost_price**: required: None -> True
|
||||
|
||||
### `product.list_price`
|
||||
- **template**: required: None -> True
|
||||
- (zusätzlich neue Felder: product)
|
||||
|
||||
### `product.product`
|
||||
- entfernte Felder: lead_times
|
||||
- (zusätzlich neue Felder: code_readonly, identifiers, list_price, list_price_used, list_prices, position, prefix_code, production_lead_times, replaced_by, suffix_code)
|
||||
|
||||
### `product.template`
|
||||
- entfernte Felder: height_digits, length_digits, volume_digits, weight_digits, width_digits
|
||||
- **lot_required**: type: Many2Many -> MultiSelection; relation: product.template-stock.lot.type -> None
|
||||
- (zusätzlich neue Felder: code, code_readonly, locations, lot_sequence)
|
||||
|
||||
### `production`
|
||||
- entfernte Felder: effective_date, planned_date, reference, unit_digits, uom
|
||||
- (zusätzlich neue Felder: origin, type, unit)
|
||||
|
||||
### `production.bom.input`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- **product**: required: True -> None
|
||||
- (zusätzlich neue Felder: phantom_bom, unit)
|
||||
|
||||
### `production.bom.output`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- **product**: required: True -> None
|
||||
- (zusätzlich neue Felder: phantom_bom, unit)
|
||||
|
||||
### `production.bom.tree`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- (zusätzlich neue Felder: unit)
|
||||
|
||||
### `production.bom.tree.open.start`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- (zusätzlich neue Felder: unit)
|
||||
|
||||
### `production.split.start`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- (zusätzlich neue Felder: unit)
|
||||
|
||||
### `res.user`
|
||||
- entfernte Felder: main_company
|
||||
- **companies**: type: Function[One2Many] -> Many2Many; relation: None -> res.user-company.company
|
||||
- **menu**: required: True -> None
|
||||
- (zusätzlich neue Felder: avatar_badge_url, company_filter, notifications, warehouse)
|
||||
|
||||
### `res.user.application`
|
||||
- **application**: required: None -> True
|
||||
|
||||
### `stock.inventory`
|
||||
- entfernte Felder: lost_found
|
||||
|
||||
### `stock.inventory.count.quantity`
|
||||
- entfernte Felder: quantity_added, quantity_resulting, unit_digits, uom
|
||||
- (zusätzlich neue Felder: lot, quantity, total_quantity, unit)
|
||||
|
||||
### `stock.inventory.count.search`
|
||||
- entfernte Felder: search_model
|
||||
|
||||
### `stock.inventory.line`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- (zusätzlich neue Felder: inventory_date, inventory_location, unit)
|
||||
|
||||
### `stock.move`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- (zusätzlich neue Felder: cost_price_required, delay, from_location_name, origin_planned_date, outcome_moves, product_cost_price, production, production_cost_price_updated, to_location_name, unit, unit_price_company, unit_price_updated)
|
||||
|
||||
### `stock.move.split.start`
|
||||
- entfernte Felder: unit_digits, uom
|
||||
- (zusätzlich neue Felder: unit)
|
||||
|
||||
### `stock.product.location`
|
||||
- **product**: required: True -> None
|
||||
- (zusätzlich neue Felder: template)
|
||||
|
||||
### `stock.shipment.in`
|
||||
- **warehouse_input**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
|
||||
- **warehouse_storage**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
|
||||
- (zusätzlich neue Felder: delay, internal_volume, internal_weight, origin_planned_date, volume_uom, weight_uom)
|
||||
|
||||
### `stock.shipment.out`
|
||||
- **warehouse_output**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
|
||||
- **warehouse_storage**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
|
||||
- (zusätzlich neue Felder: delay, internal_volume, internal_weight, origin_planned_date, packages_volume, packages_weight, partially_assigned, volume_uom, weight_uom)
|
||||
|
||||
### `stock.shipment.out.return`
|
||||
- entfernte Felder: delivery_address
|
||||
- **warehouse_input**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
|
||||
- **warehouse_storage**: type: Function[Many2One] -> Many2One; relation: None -> stock.location; required: None -> True
|
||||
- (zusätzlich neue Felder: contact_address, delay, internal_volume, internal_weight, origin_planned_date, volume_uom, weight_uom)
|
||||
|
||||
## neu (76) - Modell gibt es erst ab 8.0
|
||||
|
||||
- `company.company.logo.cache`
|
||||
- `company.company.tax_identifier`
|
||||
- `country.organization`
|
||||
- `country.organization.member`
|
||||
- `country.postal_code`
|
||||
- `country.region`
|
||||
- `currency.cron`
|
||||
- `currency.cron-currency.currency`
|
||||
- `ir.avatar`
|
||||
- `ir.avatar.cache`
|
||||
- `ir.calendar.day`
|
||||
- `ir.calendar.month`
|
||||
- `ir.chat.channel`
|
||||
- `ir.chat.follower`
|
||||
- `ir.chat.message`
|
||||
- `ir.cron.log`
|
||||
- `ir.email`
|
||||
- `ir.email.address`
|
||||
- `ir.email.template`
|
||||
- `ir.email.template-ir.action.report`
|
||||
- `ir.error`
|
||||
- `ir.export-res.group`
|
||||
- `ir.export-write-res.group`
|
||||
- `ir.filestore.queue`
|
||||
- `ir.lang.config`
|
||||
- `ir.lang.config.start`
|
||||
- `ir.message`
|
||||
- `ir.model.log`
|
||||
- `ir.model.refresh_materialized`
|
||||
- `ir.module.config.start`
|
||||
- `ir.ui.view_tree_optional`
|
||||
- `party.address.subdivision_type`
|
||||
- `party.contact_mechanism.language`
|
||||
- `party.party-delivered_to-stock.location`
|
||||
- `product.configuration.default_lot_sequence`
|
||||
- `product.cost_price.revision`
|
||||
- `product.identifier`
|
||||
- `product.modify_cost_price`
|
||||
- `product.modify_cost_price.start`
|
||||
- `product.price_list`
|
||||
- `product.price_list.line`
|
||||
- `product.product.replace`
|
||||
- `product.product.replace.ask`
|
||||
- `product.recompute_cost_price.start`
|
||||
- `res.notification`
|
||||
- `res.user-company.company`
|
||||
- `res.user.device`
|
||||
- `stock.configuration.measurement`
|
||||
- `stock.location.waste`
|
||||
- `stock.lot.trace`
|
||||
- `stock.lots_by_locations`
|
||||
- `stock.move.add.lots`
|
||||
- `stock.move.add.lots.start`
|
||||
- `stock.move.add.lots.start.lot`
|
||||
- `stock.product_quantities_warehouse.move`
|
||||
- `stock.product_quantities_warehouse.move.open`
|
||||
- `stock.product_quantities_warehouse.open`
|
||||
- `stock.products_by_locations`
|
||||
- `stock.reporting.inventory`
|
||||
- `stock.reporting.inventory.context`
|
||||
- `stock.reporting.inventory.daily`
|
||||
- `stock.reporting.inventory.move`
|
||||
- `stock.reporting.inventory.range.context`
|
||||
- `stock.reporting.inventory.turnover`
|
||||
- `stock.reporting.inventory.turnover.context`
|
||||
- `stock.reporting.margin.category`
|
||||
- `stock.reporting.margin.category.time_series`
|
||||
- `stock.reporting.margin.category.tree`
|
||||
- `stock.reporting.margin.context`
|
||||
- `stock.reporting.margin.main`
|
||||
- `stock.reporting.margin.main.time_series`
|
||||
- `stock.reporting.margin.product`
|
||||
- `stock.reporting.margin.product.time_series`
|
||||
- `stock.shipment.assign`
|
||||
- `stock.shipment.assign.partial`
|
||||
- `stock.shipment.drop`
|
||||
|
||||
## nur ergänzt (27) - nur neue Felder dazugekommen, Rest unverändert
|
||||
|
||||
- `company.employee`
|
||||
- `country.country`
|
||||
- `ir.configuration`
|
||||
- `ir.export`
|
||||
- `ir.lang`
|
||||
- `ir.session`
|
||||
- `ir.translation.export.result`
|
||||
- `ir.ui.menu`
|
||||
- `party.configuration`
|
||||
- `party.identifier`
|
||||
- `party.party`
|
||||
- `product.by_location.context`
|
||||
- `product.category`
|
||||
- `product.configuration`
|
||||
- `production.bom`
|
||||
- `production.configuration`
|
||||
- `res.group`
|
||||
- `res.user.login.attempt`
|
||||
- `stock.configuration`
|
||||
- `stock.location`
|
||||
- `stock.lot`
|
||||
- `stock.package`
|
||||
- `stock.product_quantities_warehouse`
|
||||
- `stock.product_quantities_warehouse.context`
|
||||
- `stock.products_by_locations.context`
|
||||
- `stock.shipment.in.return`
|
||||
- `stock.shipment.internal`
|
||||
|
||||
## unverändert (100) - komplett identisch geblieben
|
||||
|
||||
- `company.company.config`
|
||||
- `company.company.config.start`
|
||||
- `ir.action-res.group`
|
||||
- `ir.action.url`
|
||||
- `ir.attachment`
|
||||
- `ir.cache`
|
||||
- `ir.cron-company.company`
|
||||
- `ir.date`
|
||||
- `ir.export.line`
|
||||
- `ir.model.button-button.reset`
|
||||
- `ir.model.button-res.group`
|
||||
- `ir.model.button.click`
|
||||
- `ir.model.button.rule`
|
||||
- `ir.model.graph`
|
||||
- `ir.model.print_model_graph`
|
||||
- `ir.model.print_model_graph.start`
|
||||
- `ir.model.workflow_graph`
|
||||
- `ir.module.activate_upgrade`
|
||||
- `ir.module.activate_upgrade.done`
|
||||
- `ir.module.activate_upgrade.start`
|
||||
- `ir.module.config`
|
||||
- `ir.module.config_wizard`
|
||||
- `ir.module.config_wizard.done`
|
||||
- `ir.module.config_wizard.first`
|
||||
- `ir.module.config_wizard.other`
|
||||
- `ir.module.dependency`
|
||||
- `ir.note`
|
||||
- `ir.note.read`
|
||||
- `ir.queue`
|
||||
- `ir.rule`
|
||||
- `ir.rule.group-res.group`
|
||||
- `ir.sequence.type-res.group`
|
||||
- `ir.session.wizard`
|
||||
- `ir.translation.clean`
|
||||
- `ir.translation.clean.start`
|
||||
- `ir.translation.clean.succeed`
|
||||
- `ir.translation.export`
|
||||
- `ir.translation.export.start`
|
||||
- `ir.translation.report`
|
||||
- `ir.translation.set`
|
||||
- `ir.translation.set.start`
|
||||
- `ir.translation.set.succeed`
|
||||
- `ir.translation.update`
|
||||
- `ir.translation.update.start`
|
||||
- `ir.trigger.log`
|
||||
- `ir.ui.menu-res.group`
|
||||
- `ir.ui.menu.favorite`
|
||||
- `ir.ui.view.show`
|
||||
- `ir.ui.view.show.start`
|
||||
- `ir.ui.view_tree_state`
|
||||
- `party.category`
|
||||
- `party.check_vies`
|
||||
- `party.check_vies.result`
|
||||
- `party.configuration.party_lang`
|
||||
- `party.configuration.party_sequence`
|
||||
- `party.erase`
|
||||
- `party.erase.ask`
|
||||
- `party.letter`
|
||||
- `party.party-party.category`
|
||||
- `party.party.lang`
|
||||
- `party.party.location`
|
||||
- `party.replace`
|
||||
- `party.replace.ask`
|
||||
- `product.configuration.default_cost_price_method`
|
||||
- `product.cost_price_method`
|
||||
- `product.product-production.bom`
|
||||
- `product.recompute_cost_price`
|
||||
- `product.template-product.category`
|
||||
- `product.template-product.category.all`
|
||||
- `product.uom`
|
||||
- `product.uom.category`
|
||||
- `production.bom.tree.open`
|
||||
- `production.bom.tree.open.tree`
|
||||
- `production.configuration.production_sequence`
|
||||
- `production.lead_time`
|
||||
- `production.split`
|
||||
- `res.user-company.employee`
|
||||
- `res.user-ir.action`
|
||||
- `res.user-res.group`
|
||||
- `res.user.config`
|
||||
- `res.user.config.start`
|
||||
- `res.user.email_reset_password`
|
||||
- `res.user.warning`
|
||||
- `stock.configuration.location`
|
||||
- `stock.configuration.sequence`
|
||||
- `stock.inventory.count`
|
||||
- `stock.location.lead_time`
|
||||
- `stock.location.lot.shelf_life`
|
||||
- `stock.lots_by_location.context`
|
||||
- `stock.move.split`
|
||||
- `stock.period`
|
||||
- `stock.period.cache`
|
||||
- `stock.period.cache.lot`
|
||||
- `stock.shipment.in.restocking_list`
|
||||
- `stock.shipment.internal.report`
|
||||
- `stock.shipment.out.delivery_note`
|
||||
- `stock.shipment.out.picking_list`
|
||||
- `stock.shipment.out.return.restocking_list`
|
||||
- `stock.shipment.split`
|
||||
- `stock.shipment.split.start`
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rst_to_dataset.py
|
||||
|
||||
Extrahiert aus Tryton-Modul-Testdateien (tests/scenario_*.rst - das sind
|
||||
ausführbare Doctests mit Proteus-Code in >>>-Zeilen) automatisch Record-
|
||||
Definitionen im dataset.yaml-Format von create_dataset.py.
|
||||
|
||||
Erkennt:
|
||||
>>> Party = Model.get('party.party') # Modell-Zuordnung
|
||||
>>> party = Party(name='Testfirma GmbH') # Instanziierung + Felder
|
||||
>>> party.vat_number = '123' # weitere Feld-Zuweisung
|
||||
>>> party.save() # Abschluss -> Record wird übernommen
|
||||
|
||||
sowie Referenzen auf andere in derselben Datei bereits angelegte Records
|
||||
(z. B. `address.party = party`) -> werden zu {"$ref": "..."}, und die
|
||||
gängigsten nicht-JSON-tauglichen Werte:
|
||||
Decimal('12.34') -> {"$decimal": "12.34"}
|
||||
datetime.date(2024, 1, 1) -> {"$date": "2024-01-01"}
|
||||
|
||||
Alles andere (Wizards, .click(...), Reports, One2Many .new()/.append(),
|
||||
Aufrufe von Hilfsfunktionen wie create_company()) wird NICHT automatisch
|
||||
aufgelöst, sondern als {"$manual": "<Original-Ausdruck>"} markiert bzw.
|
||||
komplett übersprungen und in der Zusammenfassung aufgelistet - das ist
|
||||
bewusst so: lieber sichtbar unvollständig als still falsch.
|
||||
|
||||
Nutzung:
|
||||
python3 rst_to_dataset.py tests/scenario_party.rst tests/scenario_address.rst \\
|
||||
--output-dir extracted/
|
||||
|
||||
Erzeugt extracted/scenario_party.yaml, extracted/scenario_address.yaml.
|
||||
Record-Keys werden mit dem Dateinamen präfixiert (party__party_1 statt
|
||||
nur party_1), damit beim manuellen Zusammenführen mehrerer Module keine
|
||||
Kollisionen entstehen.
|
||||
|
||||
WICHTIG: Ungetestet gegen echte .rst-Dateien (hier ohne Netzwerkzugriff auf
|
||||
das Tryton-Repo) - nur gegen eine selbstgebaute Beispieldatei verifiziert
|
||||
(siehe Testlauf in der Antwort). Bitte an echten Dateien gegenprüfen, bevor
|
||||
du dich auf die Ausgabe verlässt - der "manual"-Anteil wird bei komplexeren
|
||||
Szenarien vermutlich größer sein als im Testfall.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
|
||||
PROMPT_RE = re.compile(r'^\s*>>>\s?(.*)$')
|
||||
CONTINUATION_RE = re.compile(r'^\s*\.\.\.\s?(.*)$')
|
||||
|
||||
|
||||
def extract_doctest_source(text):
|
||||
"""Zieht alle >>>/...-Zeilen aus einer .rst-Datei und reiht sie zu einem
|
||||
zusammenhängenden Python-Quelltext aneinander. Alles andere (Prosa,
|
||||
erwartete Ausgabe) wird ignoriert."""
|
||||
lines = []
|
||||
for raw_line in text.splitlines():
|
||||
m = PROMPT_RE.match(raw_line)
|
||||
if m:
|
||||
lines.append(m.group(1))
|
||||
continue
|
||||
m = CONTINUATION_RE.match(raw_line)
|
||||
if m:
|
||||
lines.append(m.group(1))
|
||||
continue
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def is_call_to(node, *dotted_names):
|
||||
"""True, wenn node ein Call ist, dessen func-Ausdruck (als unparse-Text)
|
||||
zu einem der übergebenen Namen passt, z. B. 'Model.get' oder 'Decimal'."""
|
||||
if not isinstance(node, ast.Call):
|
||||
return False
|
||||
try:
|
||||
text = ast.unparse(node.func)
|
||||
except Exception:
|
||||
return False
|
||||
return text in dotted_names
|
||||
|
||||
|
||||
def resolve_model_get(node):
|
||||
"""Liefert den Modellnamen aus einem Model.get('x.y')-Aufruf, sonst None."""
|
||||
if not is_call_to(node, "Model.get"):
|
||||
return None
|
||||
if len(node.args) != 1 or not isinstance(node.args[0], ast.Constant):
|
||||
return None
|
||||
if not isinstance(node.args[0].value, str):
|
||||
return None
|
||||
return node.args[0].value
|
||||
|
||||
|
||||
def resolve_find_call(node, model_vars):
|
||||
"""Erkennt `SomeModelVar.find([('feld', '=', 'wert'), ...])` bzw.
|
||||
`.search(...)` - das Standardmuster, mit dem Tryton-Tests auf bereits
|
||||
vorhandene Stammdaten zugreifen (Länder, Währungen, Einheiten,
|
||||
Lagerorte, ... - i. d. R. per XML-Fixture beim Modul-Aktivieren
|
||||
angelegt, nicht Teil des Testdatensatzes selbst). Liefert
|
||||
(model_name, domain) oder None, wenn das Domain-Argument nicht rein
|
||||
literal ist (z. B. eine Variable referenziert) oder das Muster nicht
|
||||
passt."""
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
||||
return None
|
||||
if node.func.attr not in ("find", "search"):
|
||||
return None
|
||||
if not isinstance(node.func.value, ast.Name) or node.func.value.id not in model_vars:
|
||||
return None
|
||||
if not node.args:
|
||||
return None
|
||||
try:
|
||||
domain = ast.literal_eval(node.args[0])
|
||||
except Exception:
|
||||
return None
|
||||
return model_vars[node.func.value.id], domain
|
||||
|
||||
|
||||
class ScenarioParser:
|
||||
def __init__(self):
|
||||
self.model_vars = {} # Variable -> Modellname (aus Model.get(...))
|
||||
self.record_vars = {} # Variable -> Record-Dict (key/model/fields)
|
||||
self.search_vars = {} # Variable -> {"model": ..., "domain": ...} (aus X.find(...)/.search(...))
|
||||
self.finalized = set() # Variablen, deren erster .save()-Aufruf schon verarbeitet wurde
|
||||
self.output = [] # fertige Record-Dicts in Reihenfolge
|
||||
self.manual_notes = [] # Zeilen, die nicht automatisch verstanden wurden
|
||||
|
||||
def convert_value(self, node):
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id in self.record_vars:
|
||||
return {"$ref": self.record_vars[node.id]["key"]}
|
||||
if node.id in self.search_vars:
|
||||
return {"$search": self.search_vars[node.id]}
|
||||
return self._manual(node)
|
||||
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
||||
inner = self.convert_value(node.operand)
|
||||
if isinstance(inner, (int, float)):
|
||||
return -inner
|
||||
return self._manual(node)
|
||||
|
||||
if isinstance(node, ast.Call):
|
||||
if is_call_to(node, "Decimal") and node.args and isinstance(node.args[0], ast.Constant):
|
||||
return {"$decimal": str(node.args[0].value)}
|
||||
|
||||
if is_call_to(node, "datetime.date", "date") and len(node.args) >= 3:
|
||||
try:
|
||||
y, m, d = (a.value for a in node.args[:3])
|
||||
return {"$date": f"{int(y):04d}-{int(m):02d}-{int(d):02d}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self._manual(node)
|
||||
|
||||
if isinstance(node, (ast.List, ast.Tuple)):
|
||||
return [self.convert_value(el) for el in node.elts]
|
||||
|
||||
return self._manual(node)
|
||||
|
||||
def _manual(self, node):
|
||||
try:
|
||||
text = ast.unparse(node)
|
||||
except Exception:
|
||||
text = "<nicht darstellbar>"
|
||||
self.manual_notes.append(f"Zeile {getattr(node, 'lineno', '?')}: {text}")
|
||||
return {"$manual": text}
|
||||
|
||||
def make_key(self, var_name):
|
||||
base = var_name
|
||||
key = base
|
||||
i = 2
|
||||
existing_keys = {r["key"] for r in self.output} | {
|
||||
r["key"] for r in self.record_vars.values()
|
||||
}
|
||||
while key in existing_keys:
|
||||
key = f"{base}_{i}"
|
||||
i += 1
|
||||
return key
|
||||
|
||||
def handle_assign(self, node):
|
||||
if len(node.targets) != 1:
|
||||
self._manual(node)
|
||||
return
|
||||
target = node.targets[0]
|
||||
|
||||
# x, = SomeModelVar.find([('feld', '=', 'wert')]) (Zugriff auf
|
||||
# vorhandene Stammdaten statt Anlegen eines neuen Records)
|
||||
if (
|
||||
isinstance(target, ast.Tuple)
|
||||
and len(target.elts) == 1
|
||||
and isinstance(target.elts[0], ast.Name)
|
||||
):
|
||||
found = resolve_find_call(node.value, self.model_vars)
|
||||
if found:
|
||||
model_name, domain = found
|
||||
self.search_vars[target.elts[0].id] = {"model": model_name, "domain": domain}
|
||||
return
|
||||
self._manual(node)
|
||||
return
|
||||
|
||||
# X = Model.get('some.model')
|
||||
if isinstance(target, ast.Name):
|
||||
model_name = resolve_model_get(node.value)
|
||||
if model_name:
|
||||
self.model_vars[target.id] = model_name
|
||||
return
|
||||
|
||||
# x = SomeModelVar(...) ODER x = Model.get('x.y')(...)
|
||||
model_name = None
|
||||
kwargs = {}
|
||||
if isinstance(node.value, ast.Call):
|
||||
func = node.value.func
|
||||
if isinstance(func, ast.Name) and func.id in self.model_vars:
|
||||
model_name = self.model_vars[func.id]
|
||||
else:
|
||||
model_name = resolve_model_get(func)
|
||||
|
||||
if model_name:
|
||||
for kw in node.value.keywords:
|
||||
if kw.arg:
|
||||
kwargs[kw.arg] = self.convert_value(kw.value)
|
||||
|
||||
if model_name:
|
||||
key = self.make_key(target.id)
|
||||
self.record_vars[target.id] = {
|
||||
"key": key,
|
||||
"model": model_name,
|
||||
"fields": kwargs,
|
||||
}
|
||||
return
|
||||
|
||||
# x = <irgendwas anderes>, z. B. Ergebnis einer Helper-Funktion
|
||||
# (create_company(), Party.find([...])[0], ...) - können wir
|
||||
# nicht auflösen.
|
||||
self.manual_notes.append(
|
||||
f"Zeile {node.lineno}: '{target.id} = {ast.unparse(node.value)}' "
|
||||
f"nicht automatisch aufgelöst (kein erkanntes Modell/Konstruktor)."
|
||||
)
|
||||
return
|
||||
|
||||
# x.feld = value
|
||||
if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name):
|
||||
var = target.value.id
|
||||
if var in self.record_vars:
|
||||
self.record_vars[var]["fields"][target.attr] = self.convert_value(node.value)
|
||||
return
|
||||
|
||||
self._manual(node)
|
||||
|
||||
def handle_call_statement(self, node):
|
||||
call = node.value
|
||||
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute):
|
||||
return
|
||||
if call.func.attr != "save":
|
||||
return
|
||||
if not isinstance(call.func.value, ast.Name):
|
||||
return
|
||||
|
||||
var = call.func.value.id
|
||||
if var in self.record_vars and var not in self.finalized:
|
||||
self.output.append(dict(self.record_vars[var]))
|
||||
self.finalized.add(var)
|
||||
|
||||
def parse(self, source):
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError as e:
|
||||
raise ValueError(f"Konnte extrahierten Doctest-Code nicht parsen: {e}") from e
|
||||
|
||||
for stmt in tree.body:
|
||||
if isinstance(stmt, ast.Assign):
|
||||
self.handle_assign(stmt)
|
||||
elif isinstance(stmt, ast.Expr):
|
||||
self.handle_call_statement(stmt)
|
||||
# alles andere (if/for/import/...) wird bewusst ignoriert
|
||||
|
||||
|
||||
def namespace_keys(records, prefix):
|
||||
"""Schreibt allen Record-Keys (und $ref-Verweisen darauf) ein Präfix
|
||||
voran, damit beim manuellen Zusammenführen mehrerer Module-YAMLs keine
|
||||
Kollisionen entstehen."""
|
||||
rename = {r["key"]: f"{prefix}__{r['key']}" for r in records}
|
||||
|
||||
def rewrite(value):
|
||||
if isinstance(value, dict):
|
||||
if "$ref" in value and value["$ref"] in rename:
|
||||
return {"$ref": rename[value["$ref"]]}
|
||||
return {k: rewrite(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [rewrite(v) for v in value]
|
||||
return value
|
||||
|
||||
result = []
|
||||
for r in records:
|
||||
result.append({
|
||||
"key": rename[r["key"]],
|
||||
"model": r["model"],
|
||||
"fields": {k: rewrite(v) for k, v in r["fields"].items()},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def process_file(path, output_dir):
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
source = extract_doctest_source(text)
|
||||
|
||||
parser = ScenarioParser()
|
||||
parser.parse(source)
|
||||
|
||||
prefix = Path(path).stem
|
||||
records = namespace_keys(parser.output, prefix)
|
||||
|
||||
out_path = Path(output_dir) / f"{prefix}.yaml"
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
doc = {"records": records}
|
||||
if yaml is not None:
|
||||
out_path.write_text(
|
||||
yaml.dump(doc, allow_unicode=True, sort_keys=False, default_flow_style=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
import json
|
||||
out_path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
return out_path, len(records), parser.manual_notes
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("rst_files", nargs="+", help="Ein oder mehrere scenario_*.rst-Dateien")
|
||||
parser.add_argument("--output-dir", default="extracted", help="Zielordner für die YAMLs")
|
||||
args = parser.parse_args()
|
||||
|
||||
for rst_file in args.rst_files:
|
||||
try:
|
||||
out_path, count, notes = process_file(rst_file, args.output_dir)
|
||||
except ValueError as e:
|
||||
print(f"{rst_file}: FEHLER - {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
print(f"{rst_file} -> {out_path} ({count} Record(s) erkannt)")
|
||||
if notes:
|
||||
print(f" {len(notes)} Ausdruck/Ausdrücke nicht automatisch aufgelöst (siehe $manual im YAML):")
|
||||
for note in notes[:10]:
|
||||
print(f" - {note}")
|
||||
if len(notes) > 10:
|
||||
print(f" ... und {len(notes) - 10} weitere")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rsts_merge_dedup.py
|
||||
|
||||
Liest alle von rst_to_dataset.py erzeugten *.yaml-Dateien aus einem
|
||||
Verzeichnis ein, entfernt Records mit identischem model+fields (die Keys
|
||||
unterscheiden sich zwar je Datei durch das Präfix, der Inhalt ist aber oft
|
||||
gleich, z. B. dasselbe Basis-product.template in vielen Szenarien) und
|
||||
schreibt eine einzige zusammengeführte, nach model sortierte Datei.
|
||||
|
||||
$ref-Verweise auf entfernte Duplikate werden automatisch auf den jeweils
|
||||
behaltenen Record umgeschrieben.
|
||||
|
||||
ACHTUNG: Es wird bewusst GLOBAL dedupliziert, auch innerhalb derselben
|
||||
Datei. Das kann bei Szenarien, die zwei absichtlich gleich aussehende
|
||||
Records anlegen (z. B. scenario_party_replace.yaml: zwei Parteien namens
|
||||
"Pam", um eine davon zu ersetzen), einen der beiden Records entfernen und
|
||||
damit den Sinn des Szenarios verändern. Für den Zweck hier (grober
|
||||
Testdatensatz) ist das laut Absprache in Ordnung - falls die Datei später
|
||||
1:1 für szenario-treue Tests verwendet werden soll, nochmal prüfen.
|
||||
|
||||
Nutzung:
|
||||
python3 rsts_merge_dedup.py rsts/8e --output reports/8_testdata_merged.yaml
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import heapq
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
|
||||
def load_records(path):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if yaml is not None:
|
||||
data = yaml.safe_load(text)
|
||||
else:
|
||||
data = json.loads(text)
|
||||
return data.get("records", [])
|
||||
|
||||
|
||||
def rewrite_refs(value, rename):
|
||||
if isinstance(value, dict):
|
||||
if "$ref" in value:
|
||||
return {"$ref": rename.get(value["$ref"], value["$ref"])}
|
||||
return {k: rewrite_refs(v, rename) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [rewrite_refs(v, rename) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def merge_and_dedupe(input_dir):
|
||||
all_records = []
|
||||
for path in sorted(Path(input_dir).glob("*.yaml")):
|
||||
for record in load_records(path):
|
||||
all_records.append((path.name, record))
|
||||
|
||||
# Signatur = model + fields (Key bewusst ignoriert, da der sich je
|
||||
# Datei nur durchs Präfix unterscheidet).
|
||||
canonical_key = {} # signatur -> key des ersten Fundes
|
||||
rename = {} # jeder key -> key des ersten Fundes derselben Signatur
|
||||
kept = []
|
||||
removed = 0
|
||||
|
||||
for source, record in all_records:
|
||||
signature = (record["model"], json.dumps(record["fields"], sort_keys=True))
|
||||
if signature in canonical_key:
|
||||
rename[record["key"]] = canonical_key[signature]
|
||||
removed += 1
|
||||
continue
|
||||
canonical_key[signature] = record["key"]
|
||||
rename[record["key"]] = record["key"]
|
||||
kept.append(record)
|
||||
|
||||
for record in kept:
|
||||
record["fields"] = rewrite_refs(record["fields"], rename)
|
||||
|
||||
kept = topo_sort_by_model(kept)
|
||||
|
||||
return kept, removed
|
||||
|
||||
|
||||
def find_refs(value):
|
||||
if isinstance(value, dict):
|
||||
if "$ref" in value:
|
||||
yield value["$ref"]
|
||||
else:
|
||||
for v in value.values():
|
||||
yield from find_refs(v)
|
||||
elif isinstance(value, list):
|
||||
for v in value:
|
||||
yield from find_refs(v)
|
||||
|
||||
|
||||
def topo_sort_by_model(records):
|
||||
"""Sortiert primär nach model, respektiert dabei aber zwingend die
|
||||
$ref-Abhängigkeiten (ein Record muss nach allem stehen, worauf er per
|
||||
$ref verweist). Kahn-Algorithmus: unter allen gerade freien Records
|
||||
(alle Abhängigkeiten schon platziert) wird immer der mit dem
|
||||
alphabetisch kleinsten model+key gewählt - das gruppiert gleiche
|
||||
models so weit, wie es die Abhängigkeiten zulassen."""
|
||||
by_key = {r["key"]: r for r in records}
|
||||
deps = {r["key"]: set() for r in records}
|
||||
dependents = {r["key"]: set() for r in records}
|
||||
|
||||
for r in records:
|
||||
for ref in find_refs(r["fields"]):
|
||||
if ref in by_key and ref != r["key"]:
|
||||
deps[r["key"]].add(ref)
|
||||
dependents[ref].add(r["key"])
|
||||
|
||||
indegree = {k: len(v) for k, v in deps.items()}
|
||||
heap = [(by_key[k]["model"], k) for k, deg in indegree.items() if deg == 0]
|
||||
heapq.heapify(heap)
|
||||
|
||||
result = []
|
||||
while heap:
|
||||
_, key = heapq.heappop(heap)
|
||||
result.append(by_key[key])
|
||||
for dependent in dependents[key]:
|
||||
indegree[dependent] -= 1
|
||||
if indegree[dependent] == 0:
|
||||
heapq.heappush(heap, (by_key[dependent]["model"], dependent))
|
||||
|
||||
if len(result) != len(records):
|
||||
stuck = set(by_key) - {r["key"] for r in result}
|
||||
raise ValueError(
|
||||
f"Zyklische oder unauflösbare $ref-Abhängigkeiten bei: {sorted(stuck)}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("input_dir", help="Verzeichnis mit den *.yaml-Dateien (z. B. rsts/8e)")
|
||||
parser.add_argument("--output", required=True, help="Zieldatei für den zusammengeführten Datensatz")
|
||||
args = parser.parse_args()
|
||||
|
||||
kept, removed = merge_and_dedupe(args.input_dir)
|
||||
|
||||
doc = {"records": kept}
|
||||
out_path = Path(args.output)
|
||||
if yaml is not None:
|
||||
out_path.write_text(
|
||||
yaml.dump(doc, allow_unicode=True, sort_keys=False, default_flow_style=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
out_path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
print(f"{len(kept)} Records behalten, {removed} Duplikate entfernt -> {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -4,7 +4,7 @@ set -e
|
||||
|
||||
TRYTON_REPO="$HOME/tmp/tryton"
|
||||
WORKDIR="$HOME/code/tryton-db-migration"
|
||||
EXTRACTOR="$WORKDIR/extract.py"
|
||||
EXTRACTOR="$WORKDIR/extract_updates.py"
|
||||
|
||||
|
||||
START='5.0'
|
||||
@@ -47,7 +47,7 @@ echo
|
||||
echo "starte Auswertung"
|
||||
echo "======================================================"
|
||||
|
||||
python3 compare.py
|
||||
python3 compare_updates.py
|
||||
|
||||
echo
|
||||
echo "Fertig."
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sql_dump_diff.py
|
||||
|
||||
Vergleicht zwei mit `pg_dump` (Klartext-Format, --format=plain) erzeugte
|
||||
SQL-Dumps strukturell: welche Tabellen/Spalten gibt es in Version A, die
|
||||
es in B nicht (mehr) gibt (und umgekehrt), welche Spaltentypen haben sich
|
||||
geändert, und wie viele Zeilen stehen jeweils in einer gemeinsamen
|
||||
Tabelle. Gedacht für zwei frisch initialisierte (oder später: mit
|
||||
denselben Proteus-Testdaten befüllte) Datenbanken zweier Tryton-Versionen
|
||||
- als dritte, von der Code-Analyse (extract_updates.py/model_diff.py)
|
||||
unabhängige Quelle für "was hat sich am Schema wirklich geändert".
|
||||
|
||||
Mit --model-diff (Ausgabe von model_diff.py) wird zusätzlich pro
|
||||
betroffener Tabelle geprüft, ob die rein statische Code-Analyse dieselbe
|
||||
Änderung schon vorhergesagt hatte (Modellname -> Tabellenname: Punkte
|
||||
durch Unterstriche ersetzen, wie es extract_updates.py/
|
||||
compare_snapshots.py auch tun) - zeigt, wie gut beide Ansätze
|
||||
übereinstimmen, und macht Abweichungen sichtbar (z. B. Python-seitige
|
||||
Änderungen ohne SQL-Schemaauswirkung, oder Tabellenänderungen durch ein
|
||||
Modul, das nicht im --modules-Set von extract_model_tree.py steckte).
|
||||
|
||||
Zeilen werden bereits vollständig geparst (als dict pro Zeile, Spalte ->
|
||||
Wert) und im Rückgabewert von parse_dump() mitgeführt - für eine spätere
|
||||
inhaltliche Vergleichs-Erweiterung (z. B. per --key-column concrete Werte
|
||||
statt nur Zeilenzahlen gegenüberstellen). Der aktuelle Report nutzt davon
|
||||
bisher nur die Zeilenzahl.
|
||||
|
||||
Bekannte Grenzen: erkennt keine Indizes/Constraints/Sequences/
|
||||
Spaltenreihenfolge, nur Tabellen + Spalten(typen) + Zeilenzahl.
|
||||
|
||||
Nutzung:
|
||||
python3 sql_dump_diff.py --dump-a data/leer5.sql --label-a 5.0 \\
|
||||
--dump-b data/leer8.sql --label-b 8.0 \\
|
||||
--model-diff reports/model_diff.json \\
|
||||
--output reports/dump_diff_5.0_8.0.json \\
|
||||
--report reports/dump_diff_5.0_8.0.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
CREATE_TABLE_RE = re.compile(r'^CREATE TABLE public\.(\w+) \($')
|
||||
COPY_RE = re.compile(r'^COPY public\.(\w+) \(([^)]*)\) FROM stdin;$')
|
||||
|
||||
|
||||
def unescape_copy_value(raw):
|
||||
"""pg_dump-COPY-Escaping rückgängig machen (\\N -> NULL, \\t/\\n/\\\\ -> Zeichen)."""
|
||||
if raw == r'\N':
|
||||
return None
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
c = raw[i]
|
||||
if c == '\\' and i + 1 < len(raw):
|
||||
out.append({'t': '\t', 'n': '\n', 'r': '\r', '\\': '\\'}.get(raw[i + 1], raw[i + 1]))
|
||||
i += 2
|
||||
else:
|
||||
out.append(c)
|
||||
i += 1
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def parse_column_line(line):
|
||||
"""Eine Zeile aus dem CREATE-TABLE-Block -> (spaltenname, typ) oder (None, None)."""
|
||||
text = line.strip()
|
||||
if not text or text.startswith(('CONSTRAINT', 'PRIMARY KEY', 'UNIQUE')):
|
||||
return None, None
|
||||
text = text.rstrip(',')
|
||||
for stopper in (' NOT NULL', ' DEFAULT ', ' COLLATE '):
|
||||
idx = text.find(stopper)
|
||||
if idx != -1:
|
||||
text = text[:idx]
|
||||
parts = text.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
return None, None
|
||||
name, coltype = parts
|
||||
return name.strip('"'), coltype.strip()
|
||||
|
||||
|
||||
def parse_dump(path):
|
||||
"""Liefert {tabelle: {"columns": {name: typ}, "row_count": int, "rows": [dict, ...]}}."""
|
||||
tables = {}
|
||||
with open(path, encoding='utf-8', errors='replace') as f:
|
||||
lines = iter(f)
|
||||
for line in lines:
|
||||
stripped = line.rstrip('\n')
|
||||
|
||||
m = CREATE_TABLE_RE.match(stripped)
|
||||
if m:
|
||||
table = m.group(1)
|
||||
columns = {}
|
||||
for col_line in lines:
|
||||
col_line = col_line.rstrip('\n')
|
||||
if col_line.startswith(');'):
|
||||
break
|
||||
name, coltype = parse_column_line(col_line)
|
||||
if name:
|
||||
columns[name] = coltype
|
||||
entry = tables.setdefault(table, {"columns": {}, "row_count": 0, "rows": []})
|
||||
entry["columns"] = columns
|
||||
continue
|
||||
|
||||
m = COPY_RE.match(stripped)
|
||||
if m:
|
||||
table, col_csv = m.group(1), m.group(2)
|
||||
col_names = [c.strip() for c in col_csv.split(',')]
|
||||
rows = []
|
||||
for data_line in lines:
|
||||
data_line = data_line.rstrip('\n')
|
||||
if data_line == '\\.':
|
||||
break
|
||||
values = [unescape_copy_value(v) for v in data_line.split('\t')]
|
||||
rows.append(dict(zip(col_names, values)))
|
||||
entry = tables.setdefault(table, {"columns": {}, "row_count": 0, "rows": []})
|
||||
entry["row_count"] = len(rows)
|
||||
entry["rows"] = rows
|
||||
|
||||
return tables
|
||||
|
||||
|
||||
def diff_dumps(tables_a, tables_b):
|
||||
tables_removed = sorted(set(tables_a) - set(tables_b))
|
||||
tables_added = sorted(set(tables_b) - set(tables_a))
|
||||
common = sorted(set(tables_a) & set(tables_b))
|
||||
|
||||
changed = {}
|
||||
unchanged = []
|
||||
for table in common:
|
||||
cols_a, cols_b = tables_a[table]["columns"], tables_b[table]["columns"]
|
||||
added_cols = sorted(set(cols_b) - set(cols_a))
|
||||
removed_cols = sorted(set(cols_a) - set(cols_b))
|
||||
type_changes = {
|
||||
c: [cols_a[c], cols_b[c]]
|
||||
for c in sorted(set(cols_a) & set(cols_b))
|
||||
if cols_a[c] != cols_b[c]
|
||||
}
|
||||
row_count_a, row_count_b = tables_a[table]["row_count"], tables_b[table]["row_count"]
|
||||
|
||||
schema_changed = bool(added_cols or removed_cols or type_changes)
|
||||
if schema_changed or row_count_a != row_count_b:
|
||||
if removed_cols or type_changes:
|
||||
expected_category = "geändert"
|
||||
elif added_cols:
|
||||
expected_category = "nur ergänzt"
|
||||
else:
|
||||
expected_category = None # nur Zeilenzahl anders - keine Code-Definitionsänderung
|
||||
changed[table] = {
|
||||
"added_columns": added_cols,
|
||||
"removed_columns": removed_cols,
|
||||
"type_changes": type_changes,
|
||||
"row_count_a": row_count_a,
|
||||
"row_count_b": row_count_b,
|
||||
"schema_changed": schema_changed,
|
||||
"expected_category": expected_category,
|
||||
}
|
||||
else:
|
||||
unchanged.append(table)
|
||||
|
||||
return {
|
||||
"tables_removed": tables_removed,
|
||||
"tables_added": tables_added,
|
||||
"tables_changed": changed,
|
||||
"tables_unchanged": unchanged,
|
||||
}
|
||||
|
||||
|
||||
def build_table_to_models(model_diff):
|
||||
mapping = {}
|
||||
for model_name in model_diff:
|
||||
mapping.setdefault(model_name.replace(".", "_"), []).append(model_name)
|
||||
return mapping
|
||||
|
||||
|
||||
def model_diff_note(table, model_diff, table_to_models, expected_category):
|
||||
"""Vergleicht einen Tabellen-Fund mit der Vorhersage aus model_diff.py.
|
||||
Liefert einen kurzen Text: Übereinstimmung, Abweichung, oder kein
|
||||
passendes Modell gefunden (z. B. reine Relations-/Interntabelle)."""
|
||||
if not model_diff:
|
||||
return ""
|
||||
models = table_to_models.get(table)
|
||||
if not models:
|
||||
return " _(kein passendes Modell in model_diff gefunden)_"
|
||||
parts = []
|
||||
for model in models:
|
||||
category = model_diff[model]["category"]
|
||||
flag = "übereinstimmung" if category == expected_category else "ABWEICHUNG"
|
||||
parts.append(f"`{model}`={category} [{flag}]")
|
||||
return " _(" + "; ".join(parts) + ")_"
|
||||
|
||||
|
||||
def write_report(diff, out_path, label_a, label_b, model_diff, table_to_models):
|
||||
lines = [f"# SQL-Dump-Diff: {label_a} -> {label_b}\n"]
|
||||
|
||||
lines.append(f"\n## Tabellen entfernt ({len(diff['tables_removed'])})\n")
|
||||
for t in diff["tables_removed"]:
|
||||
lines.append(f"- `{t}`{model_diff_note(t, model_diff, table_to_models, 'entfernt')}")
|
||||
|
||||
lines.append(f"\n\n## Tabellen neu ({len(diff['tables_added'])})\n")
|
||||
for t in diff["tables_added"]:
|
||||
lines.append(f"- `{t}`{model_diff_note(t, model_diff, table_to_models, 'neu')}")
|
||||
|
||||
schema_changed = {t: i for t, i in diff["tables_changed"].items() if i["schema_changed"]}
|
||||
rows_only = {t: i for t, i in diff["tables_changed"].items() if not i["schema_changed"]}
|
||||
|
||||
lines.append(f"\n\n## Tabellen mit Schema-Änderung ({len(schema_changed)})\n")
|
||||
lines.append(
|
||||
"Spalten wurden hinzugefügt/entfernt oder haben einen anderen Typ - "
|
||||
"vergleichbar mit einer model_diff.py-Kategorie.\n"
|
||||
)
|
||||
for t, info in sorted(schema_changed.items()):
|
||||
note = model_diff_note(t, model_diff, table_to_models, info["expected_category"])
|
||||
lines.append(f"\n### `{t}`{note}")
|
||||
if info["added_columns"]:
|
||||
lines.append(f"- neue Spalten: {', '.join(info['added_columns'])}")
|
||||
if info["removed_columns"]:
|
||||
lines.append(f"- entfernte Spalten: {', '.join(info['removed_columns'])}")
|
||||
for col, (ta, tb) in info["type_changes"].items():
|
||||
lines.append(f"- **{col}**: {ta} -> {tb}")
|
||||
if info["row_count_a"] != info["row_count_b"]:
|
||||
lines.append(f"- Zeilenzahl: {info['row_count_a']} -> {info['row_count_b']}")
|
||||
|
||||
lines.append(f"\n\n## Nur Zeilenzahl unterschiedlich, Schema identisch ({len(rows_only)})\n")
|
||||
lines.append(
|
||||
"Keine Code-Definitionsänderung zu erwarten (z. B. mehr XML-Fixture-Daten in "
|
||||
"der neueren Version) - daher kein model_diff-Abgleich.\n"
|
||||
)
|
||||
for t, info in sorted(rows_only.items()):
|
||||
lines.append(f"- `{t}`: {info['row_count_a']} -> {info['row_count_b']}")
|
||||
|
||||
lines.append(f"\n\n## Unverändert ({len(diff['tables_unchanged'])})\n")
|
||||
lines.append(", ".join(f"`{t}`" for t in diff["tables_unchanged"]))
|
||||
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--dump-a", required=True)
|
||||
parser.add_argument("--dump-b", required=True)
|
||||
parser.add_argument("--label-a", default="A")
|
||||
parser.add_argument("--label-b", default="B")
|
||||
parser.add_argument("--model-diff", default=None, help="Optional: model_diff.json zum Abgleich")
|
||||
parser.add_argument("--output", default="reports/dump_diff.json")
|
||||
parser.add_argument("--report", default="reports/dump_diff.md")
|
||||
args = parser.parse_args()
|
||||
|
||||
tables_a = parse_dump(args.dump_a)
|
||||
tables_b = parse_dump(args.dump_b)
|
||||
diff = diff_dumps(tables_a, tables_b)
|
||||
|
||||
model_diff = json.loads(Path(args.model_diff).read_text(encoding="utf-8")) if args.model_diff else {}
|
||||
table_to_models = build_table_to_models(model_diff) if model_diff else {}
|
||||
|
||||
Path(args.output).write_text(json.dumps(diff, indent=2, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
||||
write_report(diff, Path(args.report), args.label_a, args.label_b, model_diff, table_to_models)
|
||||
|
||||
print(
|
||||
f"{len(diff['tables_removed'])} entfernt, {len(diff['tables_added'])} neu, "
|
||||
f"{len(diff['tables_changed'])} geändert, {len(diff['tables_unchanged'])} unverändert "
|
||||
f"-> {args.output}, {args.report}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user