diff --git a/jobcontrol.md b/jobcontrol.md new file mode 100644 index 0000000..fc44732 --- /dev/null +++ b/jobcontrol.md @@ -0,0 +1,4001 @@ +*&---------------------------------------------------------------------* +*& Report ZMM_LIFECYCLE_CODE_INHERITANCE +*&---------------------------------------------------------------------* +*& Lebenszykluscode-Vererbung für Materialstammdaten +*& Optimiert für große Stücklisten und Massendatenverarbeitung +*&---------------------------------------------------------------------* +REPORT zmm_lifecycle_code_inheritance. +TYPES: ty_percentage TYPE p LENGTH 8 DECIMALS 2. " <-- NEUE ZEILE HINZUFÜGEN +*----------------------------------------------------------------------* +* Typdefinitionen +*----------------------------------------------------------------------* +TYPES: BEGIN OF ty_material, + matnr TYPE matnr, + mtart TYPE mtart, + zzlzcod TYPE char4, + zzlzcodsort TYPE char4, " NEU: Sortiments-Code + zztyp_f4 TYPE char1, + pstat TYPE pstat_d, + mstae TYPE mstae, + level TYPE i, + disst TYPE dismm, + END OF ty_material. +TYPES: BEGIN OF ty_mattab, + matnr TYPE matnr, + mtart TYPE mtart, + disst TYPE dismm, + lzcod TYPE char4, + lzcodsort TYPE char4, + matnr_top TYPE matnr, + menge TYPE stpo-menge, + END OF ty_mattab. + +DATA: mattab TYPE STANDARD TABLE OF ty_mattab WITH HEADER LINE, + mattab2 TYPE STANDARD TABLE OF ty_mattab WITH HEADER LINE, + mattab_dummy TYPE ty_mattab, + imara TYPE mara, + stb TYPE STANDARD TABLE OF stpox, + bg TYPE STANDARD TABLE OF cscmat, + c_werks TYPE werks_d VALUE '1100'. +TYPES: BEGIN OF ty_bom_relation, + parent TYPE matnr, + child TYPE matnr, + menge TYPE stpo-menge, + level TYPE i, + END OF ty_bom_relation. + +TYPES: BEGIN OF ty_usage, + matnr TYPE matnr, + parent TYPE matnr, + parent_code TYPE char4, + usage_qty TYPE kmpmg, + END OF ty_usage. + +TYPES: BEGIN OF ty_result, + matnr TYPE matnr, + old_code TYPE char4, + new_code TYPE char4, + old_code_sort TYPE char4, " NEU + new_code_sort TYPE char4, " NEU + + changed_sort TYPE abap_bool, " NEU + changed TYPE abap_bool, + message TYPE string, + END OF ty_result. + +TYPES: BEGIN OF ty_consumption, + matnr TYPE matnr, + menge TYPE menge_d, + + gsv01 TYPE mver-gsv01, " NEU: Originalverbrauch aus VERBRAUCH_SUMMIEREN + gsv_korr TYPE mver-gsv01, " NEU: Korrigierter Verbrauch nach MSEG-Logik + + END OF ty_consumption. + +* Hash-Tabellen für Performance +TYPES: tt_material_hash TYPE HASHED TABLE OF ty_material + WITH UNIQUE KEY matnr. +TYPES: tt_bom_hash TYPE HASHED TABLE OF ty_bom_relation + WITH UNIQUE KEY parent child. +TYPES: tt_usage_std TYPE STANDARD TABLE OF ty_usage. +TYPES: tt_result TYPE STANDARD TABLE OF ty_result. + +*----------------------------------------------------------------------* +* Konstanten +*----------------------------------------------------------------------* +CONSTANTS: gc_max_hierarchy_level TYPE i VALUE 4, + gc_batch_size TYPE i VALUE 5000, + gc_max_iterations TYPE i VALUE 10, + gc_code_initial TYPE char4 VALUE 'ZZZZ', + gc_code_normal TYPE char1 VALUE 'N', + gc_code_auslauf TYPE char1 VALUE 'A', + gc_code_ersatz TYPE char1 VALUE 'E', + gc_code_sonder TYPE char1 VALUE 'S'. + +*----------------------------------------------------------------------* +* Globale Datendeklarationen +*----------------------------------------------------------------------* +DATA: gt_materials TYPE tt_material_hash, + gt_bom_relations TYPE tt_bom_hash, + gt_usages TYPE tt_usage_std, + gt_results TYPE tt_result, + gt_consumption TYPE HASHED TABLE OF ty_consumption + WITH UNIQUE KEY matnr. + + +DATA: p_mm TYPE abap_bool, + p_di TYPE abap_bool, + p_gozin TYPE abap_bool. + +TABLES mara. +*----------------------------------------------------------------------* +* Selektionsbildschirm +*----------------------------------------------------------------------* +SELECTION-SCREEN BEGIN OF BLOCK b1 WITH FRAME TITLE TEXT-001. + SELECT-OPTIONS: s_matnr FOR mara-matnr, + s_typcd FOR mara-mtart. + PARAMETERS: "p_alles TYPE abap_bool AS CHECKBOX DEFAULT '', + " p_gozin TYPE abap_bool AS CHECKBOX DEFAULT '', + p_upda TYPE abap_bool AS CHECKBOX DEFAULT 'X', + p_test TYPE abap_bool AS CHECKBOX DEFAULT ' '. +SELECTION-SCREEN END OF BLOCK b1. +* +*SELECTION-SCREEN BEGIN OF BLOCK b2 WITH FRAME TITLE TEXT-002. +* PARAMETERS: p_batch TYPE i DEFAULT 5000, +* p_maxlv TYPE i DEFAULT 4. +*SELECTION-SCREEN END OF BLOCK b2. + + + +* HIER NEUEN BLOCK EINFÜGEN +SELECTION-SCREEN BEGIN OF BLOCK b3 WITH FRAME TITLE TEXT-003. + PARAMETERS: p_lzc TYPE abap_bool RADIOBUTTON GROUP mode , "Lebenszykluscode + p_sort TYPE abap_bool RADIOBUTTON GROUP mode DEFAULT 'X'. "Sortimentscode +SELECTION-SCREEN END OF BLOCK b3. +* SELECTION-SCREEN BEGIN OF BLOCK b4 WITH FRAME TITLE TEXT-004. +* PARAMETERS: p_mm TYPE abap_bool RADIOBUTTON GROUP moe2 , "Lebenszykluscode +* p_di TYPE abap_bool RADIOBUTTON GROUP moe2 DEFAULT 'X'. "Sortimentscode +* SELECTION-SCREEN END OF BLOCK b4. +*----------------------------------------------------------------------* +* Klassendeklaration für Hauptlogik +*----------------------------------------------------------------------* +CLASS lcl_lifecycle_processor DEFINITION. + PUBLIC SECTION. + METHODS: constructor, + execute, + + get_code_percentage + IMPORTING iv_code TYPE char4 + RETURNING VALUE(rv_percentage) TYPE ty_percentage , + + display_gozinto_graph IMPORTING iv_top_material TYPE matnr, + + display_results. + + PRIVATE SECTION. + " ==================== TYPDEFINITIONEN ==================== + + " Node-Struktur für Gozinto-Graph + TYPES: BEGIN OF ty_node, + node_key TYPE matnr, + parent_key TYPE matnr, + matnr TYPE matnr, + menge TYPE kmpmg, + zzlzcod TYPE char4, + level TYPE i, + zzlzcodsort TYPE char4, + verbrauch_text TYPE string, + END OF ty_node. + + + TYPES: tt_nodes TYPE STANDARD TABLE OF ty_node WITH EMPTY KEY. + + " Material-Level für hierarchische Sortierung + TYPES: BEGIN OF ty_material_level, + matnr TYPE matnr, + level TYPE i, + END OF ty_material_level. + + + TYPES: tt_material_level TYPE STANDARD TABLE OF ty_material_level + WITH NON-UNIQUE DEFAULT KEY. + + + + " ==================== DATENDEKLARATIONEN ==================== + + + + + " BDC-Daten für MM02-Transaktion + DATA: gt_bdcdata TYPE TABLE OF bdcdata, + gt_bdcmsg TYPE TABLE OF bdcmsgcoll. + + " Progress-Indicator (optional) + DATA: mo_progress TYPE REF TO cl_progress_indicator. + + + + + + + + " ==================== METHODENDEKLARATIONEN ==================== + + " === HAUPTMETHODEN === + METHODS: load_materials, + load_consumption_data, + calculate_inheritance, + calculate_sortiment_inhe, + update_database, + update_database_sortiment. + + " === HIERARCHIE-METHODEN === + METHODS: + + build_hierarchy_from_stb + IMPORTING iv_top_material TYPE matnr OPTIONAL, + + + + + + + + transfer_to_new_structures + IMPORTING iv_top_material TYPE matnr OPTIONAL, + + build_graph_nodes + IMPORTING iv_matnr TYPE matnr + iv_parent_key TYPE matnr + iv_menge TYPE kmpmg + iv_level TYPE i + CHANGING ct_nodes TYPE tt_nodes. + + " === VERWENDUNGS-METHODEN === + METHODS: load_usages_for_components . + + + " === BERECHNUNGS-METHODEN === + METHODS: apply_dominance_rules + IMPORTING iv_matnr TYPE matnr + RETURNING VALUE(rv_code) TYPE char4, + + apply_sortiment_rules + IMPORTING iv_matnr TYPE matnr + RETURNING VALUE(rv_code) TYPE char4, + + calculate_sonder_percentage + IMPORTING iv_matnr TYPE matnr + iv_type TYPE char2 OPTIONAL + RETURNING VALUE(rv_percentage) TYPE ty_percentage. + + + + " === STÜCKLISTEN-METHODEN === + METHODS: read_bill_of_material + IMPORTING iv_matnr TYPE matnr, + + + " === DATENBANK-UPDATE-METHODEN === + + + + + bdc_dynpro + IMPORTING + iv_program TYPE bdc_prog + iv_dynpro TYPE bdc_dynr, + + bdc_field + IMPORTING + iv_fnam TYPE fnam_____4 + iv_fval TYPE bdc_fval, + + bdc_transaction + IMPORTING + iv_tcode TYPE tcode, + + Write_to_journal + IMPORTING + iv_matnr TYPE matnr + iv_old_lzcod TYPE char4 + iv_new_lzcod TYPE char4 + iv_old_lzcodsort TYPE char4 OPTIONAL + iv_new_lzcodsort TYPE char4 OPTIONAL. + + " === DEBUG-METHODEN === + METHODS: debug_show_relations, + + is_debug_material + IMPORTING iv_matnr TYPE matnr + RETURNING VALUE(rv_debug) TYPE abap_bool, + + debug_problem_material + IMPORTING + iv_matnr TYPE matnr + iv_phase TYPE string, + + debug_collect_info + IMPORTING iv_matnr TYPE matnr + RETURNING VALUE(rt_info) TYPE string_table. + + + + " === HILFS-METHODEN === + +ENDCLASS. + +*----------------------------------------------------------------------* +* Klassenimplementierung +*----------------------------------------------------------------------* +CLASS lcl_lifecycle_processor IMPLEMENTATION. + + METHOD constructor. + "CREATE OBJECT mo_progress. + ENDMETHOD. + + METHOD execute. + DATA: lv_start_time TYPE timestampl, + lv_end_time TYPE timestampl. + + GET TIME STAMP FIELD lv_start_time. + + " Phase 1: Datenladen + ""Write: / 'Phase 1: Lade Materialstammdaten...'. + load_materials( ). + + " ===== HIER EINFÜGEN: Debug nach dem Laden ===== + me->debug_show_relations( ). + ""Write: / 'Phase 2: Lade Stücklistenbeziehungen...'. + + + ""Write: / 'Phase 3: Erweitere Hierarchie...'. + "expand_hierarchy( ). + + ""Write: / 'Phase 4: Lade Verbrauchsdaten...'. + load_consumption_data( ). + + " Phase 5: Berechnungen basierend auf Auswahl durchführen + IF p_lzc = abap_true. + " Modus: Nur Lebenszykluscode (ZZLZCOD) + ""Write: / 'Phase 5: Berechne Lebenszykluscode-Vererbung (ZZLZCOD)...'. + + + + + + PERFORM save_vknr_codes. + + + calculate_inheritance( ). + + PERFORM restore_vknr_codes. + + + + + + " PERFORM protect_vknr_from_update USING '1'. + IF p_upda = abap_true AND p_test = abap_false. + ""Write: / 'Phase 6: Aktualisiere Datenbank für ZZLZCOD...'. + update_database( ). + ENDIF. + + ELSEIF p_sort = abap_true. + " Modus: Nur Sortimentscode (ZZLZCODSORT) + ""Write: / 'Phase 5: Berechne Sortiments-Lebenszykluscode-Vererbung (ZZLZCODSORT)...'. + calculate_sortiment_inhe( ). + + IF p_upda = abap_true AND p_test = abap_false. + ""Write: / 'Phase 6: Aktualisiere Datenbank für ZZLZCODSORT...'. + update_database_sortiment( ). + ENDIF. + ENDIF. + + + GET TIME STAMP FIELD lv_end_time. + DATA(lv_runtime) = lv_end_time - lv_start_time. + ""Write: / |Gesamtlaufzeit: { lv_runtime } Sekunden|. + + ENDMETHOD. + + + + + + + + + + + METHOD transfer_to_new_structures. + " Parameter-Deklaration in der Klassendeklaration hinzufügen: + " IMPORTING iv_top_material TYPE matnr OPTIONAL + + DATA: lv_material_count TYPE i, + lv_relation_count TYPE i. + + ""Write: / '=== START transfer_to_new_structures ==='. + + " Zeige welches TOP-Material übergeben wurde + IF iv_top_material IS NOT INITIAL. + ""Write: / 'TOP-Material aus Parameter:', iv_top_material. + ELSE. + ""Write: / 'WARNUNG: Kein TOP-Material übergeben!'. + ENDIF. + + " Schritt 1: Baue Hierarchie mit dem KORREKTEN TOP-Material + me->build_hierarchy_from_stb( iv_top_material = iv_top_material ). + + + + " Validierung + lv_material_count = lines( gt_materials ). + lv_relation_count = lines( gt_bom_relations ). + + ""Write: / 'Materialien in gt_materials:', lv_material_count. + ""Write: / 'Beziehungen in gt_bom_relations:', lv_relation_count. + ""Write: / '=== ENDE transfer_to_new_structures ==='. + ENDMETHOD. + + + + + + METHOD debug_show_relations. + DATA: lt_sorted TYPE STANDARD TABLE OF ty_bom_relation, + lv_current_child TYPE matnr, + lv_parent_count TYPE i, + lt_parent_codes TYPE STANDARD TABLE OF char4. + + ""Write: / ''. + ""Write: / '╔══════════════════════════════════════════════════╗'. + ""Write: / '║ VOLLSTÄNDIGE PARENT-CHILD ANALYSE ║'. + ""Write: / '╚══════════════════════════════════════════════════╝'. + + lt_sorted = gt_bom_relations. + SORT lt_sorted BY child parent. + + " Fokus auf die kritischen Materialien + DATA: lt_critical_mats TYPE STANDARD TABLE OF matnr. + APPEND 'AR13025' TO lt_critical_mats. + APPEND 'AR13026' TO lt_critical_mats. + APPEND 'AR15117' TO lt_critical_mats. + + LOOP AT lt_critical_mats INTO DATA(lv_critical_mat). + ""Write: / ''. + ""Write: / '▶▶▶ MATERIAL:', lv_critical_mat. + + CLEAR: lv_parent_count, lt_parent_codes. + + LOOP AT lt_sorted INTO DATA(ls_rel) WHERE child = lv_critical_mat. + ADD 1 TO lv_parent_count. + + READ TABLE gt_materials WITH KEY matnr = ls_rel-parent + INTO DATA(ls_parent). + + READ TABLE gt_consumption WITH KEY matnr = ls_rel-parent + INTO DATA(ls_cons). + + IF sy-subrc = 0. + ""Write: / ' ← Parent:', ls_rel-parent, + "'Code:', ls_parent-zzlzcod, + "'Menge:', ls_rel-menge, + "'Verbrauch:', ls_cons-gsv_korr. + ELSE. + ""Write: / ' ← Parent:', ls_rel-parent, + "'Code:', ls_parent-zzlzcod, + "'Menge:', ls_rel-menge, + "'Verbrauch: 0'. + ENDIF. + + APPEND ls_parent-zzlzcod TO lt_parent_codes. + ENDLOOP. + + ""Write: / ' └─ Anzahl Parents:', lv_parent_count. + + " Erwartete Berechnung + IF lv_critical_mat = 'AR13025'. + ""Write: / ' ERWARTUNG: Sollte A8T0 werden (mit 46781, 47187 als Parents)'. + ELSEIF lv_critical_mat = 'AR15117'. + ""Write: / ' ERWARTUNG: Sollte A3T0 werden (mit 46781, 47187, 48006)'. + ENDIF. + ENDLOOP. + + ""Write: / '══════════════════════════════════════════════════'. + ENDMETHOD. + + + + METHOD read_bill_of_material. + + ENDMETHOD. + + METHOD load_materials. + DATA: lt_selected_mats TYPE STANDARD TABLE OF ty_material, + lt_all_relations TYPE STANDARD TABLE OF ty_bom_relation. + + " 1. Lade selektierte Materialien inkl. Status + SELECT matnr, mtart, zzlzcod, zzlzcodsort, zztyp_f4, pstat, disst, mstae + FROM mara + INTO CORRESPONDING FIELDS OF TABLE @lt_selected_mats + WHERE matnr IN @s_matnr + AND mtart IN @s_typcd. + + " ======================================== + " *** NEU: STATUS 99 BEHANDLUNG *** + " ======================================== + DATA: lv_status99_count TYPE i. + + LOOP AT lt_selected_mats ASSIGNING FIELD-SYMBOL(). + IF -mstae = '99'. + " Status 99 = Ausgelaufen → ZZLZCODSORT leeren + -zzlzcodsort = ''. + ADD 1 TO lv_status99_count. + ENDIF. + ENDLOOP. + + IF lv_status99_count > 0. + WRITE: / 'Status 99 (Ausgelaufen):', lv_status99_count, 'Materialien → ZZLZCODSORT geleert'. + ENDIF. + + " ======================================== + " *** FILTER NUR TOP-MATERIALIEN *** + " ======================================== + DATA: lt_top_mats TYPE STANDARD TABLE OF ty_material, + lv_filtered_mats TYPE i. + + LOOP AT lt_selected_mats INTO DATA(ls_mat_check). + + " Prüfe: Existiert Material als KOMPNR (Kind) in ZPOWERBI_VC? + SELECT COUNT(*) FROM zpowerbi_vc + INTO @DATA(lv_child_count) + WHERE kompnr = @ls_mat_check-matnr. + + IF lv_child_count = 0. + " Material hat KEINE Parents → IST TOP-Material + APPEND ls_mat_check TO lt_top_mats. + ELSE. + " Material hat Parents → AUSSCHLIESSEN + ADD 1 TO lv_filtered_mats. + ENDIF. + + ENDLOOP. + + " *** Ersetze lt_selected_mats durch lt_top_mats *** + CLEAR lt_selected_mats. + lt_selected_mats = lt_top_mats. + + " 2. Füge zu gt_materials hinzu + LOOP AT lt_selected_mats INTO DATA(ls_mat). + INSERT ls_mat INTO TABLE gt_materials. + ENDLOOP. + + " 3. Für jedes Material: Lade Stückliste UND Verwendungen + LOOP AT lt_selected_mats INTO ls_mat. + + " 3a. Top-Down: Stückliste lesen + me->read_bill_of_material( iv_matnr = ls_mat-matnr ). + me->build_hierarchy_from_stb( iv_top_material = ls_mat-matnr ). + + CLEAR: stb, bg. + + me->debug_problem_material( + iv_matnr = ls_mat-matnr + iv_phase = 'LOAD' ). + ENDLOOP. + + me->load_usages_for_components( ). + + ENDMETHOD. + + + METHOD load_usages_for_components. + DATA: lt_all_components TYPE SORTED TABLE OF matnr WITH UNIQUE KEY table_line, + lt_vc_raw TYPE STANDARD TABLE OF zpowerbi_vc, + ls_bom_rel TYPE ty_bom_relation, + ls_material TYPE ty_material, + lv_added_count TYPE i, + lv_vc_count TYPE i. + + " ======================================== + " SCHRITT 1: SAMMLE ALLE KOMPONENTEN + " ======================================== + LOOP AT gt_bom_relations INTO DATA(ls_rel). + INSERT ls_rel-child INTO TABLE lt_all_components. + ENDLOOP. + + " ======================================== + " SCHRITT 2: FÜR JEDE KOMPONENTE - BOTTOM-UP VIA ZPOWERBI_VC + " ======================================== + LOOP AT lt_all_components INTO DATA(lv_component). + + CLEAR lt_vc_raw. + + SELECT matnr, kompnr, menge, stufe + FROM zpowerbi_vc + INTO CORRESPONDING FIELDS OF TABLE @lt_vc_raw + WHERE kompnr = @lv_component. + + IF sy-subrc = 0. + lv_vc_count = lv_vc_count + lines( lt_vc_raw ). + ENDIF. + + " ======================================== + " SCHRITT 3: VERARBEITE JEDE VERWENDUNG + " ======================================== + LOOP AT lt_vc_raw INTO DATA(ls_vc_raw). + + READ TABLE gt_bom_relations + WITH KEY parent = ls_vc_raw-matnr + child = lv_component + TRANSPORTING NO FIELDS. + + IF sy-subrc <> 0. + CLEAR ls_bom_rel. + ls_bom_rel-parent = ls_vc_raw-matnr. + ls_bom_rel-child = lv_component. + + DATA(lv_menge_char) = ls_vc_raw-menge. + DATA(lv_menge_numeric) = CONV menge_d( lv_menge_char ). + ls_bom_rel-menge = lv_menge_numeric. + + DATA(lv_stufe_char) = ls_vc_raw-stufe. + DATA(lv_stufe_int) = CONV i( lv_stufe_char ). + ls_bom_rel-level = lv_stufe_int. + + INSERT ls_bom_rel INTO TABLE gt_bom_relations. + ADD 1 TO lv_added_count. + + " Parent-Material zu gt_materials hinzufügen falls unbekannt + READ TABLE gt_materials WITH KEY matnr = ls_bom_rel-parent + TRANSPORTING NO FIELDS. + IF sy-subrc <> 0. + CLEAR ls_material. + SELECT SINGLE matnr, mtart, zzlzcod, zzlzcodsort, + zztyp_f4, pstat, disst, mstae + FROM mara + INTO CORRESPONDING FIELDS OF @ls_material + WHERE matnr = @ls_bom_rel-parent. + + IF sy-subrc = 0. + " *** NEU: Status 99 prüfen *** + IF ls_material-mstae = '99'. + ls_material-zzlzcodsort = ''. + ENDIF. + INSERT ls_material INTO TABLE gt_materials. + ENDIF. + ENDIF. + ENDIF. + ENDLOOP. + + ENDLOOP. + + ENDMETHOD. + + + METHOD load_consumption_data. + DATA: w_datum_von LIKE sy-datum, + w_datum_bis LIKE sy-datum, + w_mblnr_von TYPE mkpf-mblnr, + w_mblnr_bis TYPE mkpf-mblnr, + lt_all_materials TYPE SORTED TABLE OF matnr WITH UNIQUE KEY table_line, + ls_consumption TYPE ty_consumption, + lt_mseg TYPE STANDARD TABLE OF mseg. + + ""Write: / 'Lade Verbrauchsdaten...'. + + " Sammle ALLE Materialien (Parents UND Children) + LOOP AT gt_bom_relations INTO DATA(ls_rel). + INSERT ls_rel-parent INTO TABLE lt_all_materials. + INSERT ls_rel-child INTO TABLE lt_all_materials. + ENDLOOP. + + " Zusätzlich: Alle Materialien aus gt_materials + LOOP AT gt_materials INTO DATA(ls_mat). + INSERT ls_mat-matnr INTO TABLE lt_all_materials. + ENDLOOP. + + IF lt_all_materials IS INITIAL. + ""Write: / 'Keine Materialien für Verbrauchsermittlung gefunden.'. + RETURN. + ENDIF. + + " Datumsbereiche + w_datum_bis = sy-datum. + WHILE w_datum_bis+4(2) = sy-datum+4(2). + w_datum_bis = w_datum_bis - 1. + ENDWHILE. + w_datum_von = w_datum_bis - 360. + w_datum_von+6(2) = '01'. + + " MKPF-Belegnummern-Bereich + SELECT MIN( mblnr ) INTO @w_mblnr_von + FROM mkpf + WHERE budat >= @w_datum_von AND blart = 'WL'. + + SELECT MAX( mblnr ) INTO @w_mblnr_bis + FROM mkpf + WHERE budat <= @w_datum_bis AND blart = 'WL'. + + " Verbrauch für ALLE Materialien ermitteln + LOOP AT lt_all_materials INTO DATA(lv_matnr). + CLEAR ls_consumption. + ls_consumption-matnr = lv_matnr. + + " Basis-Verbrauch + CALL FUNCTION 'VERBRAUCH_SUMMIEREN' + EXPORTING + abdatum = w_datum_von + bisdatum = w_datum_bis + matnr = lv_matnr + periv = ' ' + perkz = 'M' + werks = '1100' + IMPORTING + gesamtverbrauch = ls_consumption-gsv01 + EXCEPTIONS + OTHERS = 1. + + " MSEG-Korrekturen + CLEAR lt_mseg. + SELECT matnr, menge, bwart, shkzg + FROM mseg + INTO CORRESPONDING FIELDS OF TABLE @lt_mseg + WHERE matnr = @lv_matnr + AND werks = '1100' + AND lgort = '0001' + AND sobkz = 'E' + AND mblnr BETWEEN @w_mblnr_von AND @w_mblnr_bis + AND bwart IN ('601', '602', '651', '654'). + + LOOP AT lt_mseg INTO DATA(ls_mseg). + DATA(lv_menge) = ls_mseg-menge. + IF ls_mseg-shkzg = 'S'. + lv_menge = lv_menge * -1. + ENDIF. + ls_consumption-gsv01 = ls_consumption-gsv01 + lv_menge. + ENDLOOP. + + ls_consumption-gsv_korr = ls_consumption-gsv01. + INSERT ls_consumption INTO TABLE gt_consumption. + ENDLOOP. + + + + " !!! DEBUG: Verbrauch für kritische Materialien + IF lv_matnr = 'B58383' OR lv_matnr = 'B53618' OR + lv_matnr = 'B56383' OR lv_matnr = 'B69327'. + "Write: / '!!! VERBRAUCH für', lv_matnr, ':'. + "Write: / '!!! gsv01 (Original):', ls_consumption-gsv01. + "Write: / '!!! gsv_korr (Korrigiert):', ls_consumption-gsv_korr. + "Write: / '!!! Anzahl MSEG-Korrekturen:', lines( lt_mseg ). + ENDIF. + ""Write: / |{ lines( gt_consumption ) } Verbrauchsdatensätze geladen|. + ENDMETHOD. + + + + + METHOD calculate_inheritance. + DATA: lt_materials_sorted TYPE STANDARD TABLE OF ty_material, + + + ls_material TYPE ty_material, + ls_child TYPE ty_material, + lv_changed TYPE abap_bool, + lv_iteration TYPE i. + + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ STARTE VERERBUNGSBERECHNUNG ║'. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + + " ======================================== + " SCHRITT 1: INITIALISIERE ALLE MATERIALIEN MIT LEVEL 999 + " ======================================== + LOOP AT gt_materials INTO ls_material. + ls_material-level = 999. + MODIFY TABLE gt_materials FROM ls_material. + ENDLOOP. + + " ======================================== + " SCHRITT 2: SETZE LEVEL FÜR TOP-MATERIALIEN + " ======================================== + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ HIERARCHIE-LEVEL BESTIMMUNG ║'. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + + "Write: / 'Materialien initialisiert:', lines( gt_materials ). + + LOOP AT gt_materials INTO ls_material. + READ TABLE gt_bom_relations WITH KEY child = ls_material-matnr + TRANSPORTING NO FIELDS. + + IF sy-subrc <> 0. + ls_material-level = 0. + MODIFY TABLE gt_materials FROM ls_material. + "Write: / ' TOP-Material (Level 0):', ls_material-matnr. + ENDIF. + ENDLOOP. + + " ======================================== + " SCHRITT 3: ÜBERNEHME LEVEL AUS gt_bom_relations + " ======================================== + DATA lv_level_changes TYPE i VALUE 0. + + LOOP AT gt_bom_relations INTO DATA(ls_rel). + READ TABLE gt_materials WITH KEY matnr = ls_rel-child + INTO ls_child. + + IF sy-subrc = 0. + IF ls_rel-level < ls_child-level. + ls_child-level = ls_rel-level. + MODIFY TABLE gt_materials FROM ls_child. + ADD 1 TO lv_level_changes. + + IF lv_level_changes <= 10. + "Write: / ' Level gesetzt:', ls_child-matnr, 'Level:', ls_child-level. + ENDIF. + ENDIF. + ENDIF. + ENDLOOP. + + "Write: / 'Level-Änderungen:', lv_level_changes. + + " ======================================== + " SCHRITT 4: SORTIERE NACH LEVEL + " ======================================== + "Write: / ''. + "Write: / '═══ FINALE VERARBEITUNGSREIHENFOLGE ═══'. + + LOOP AT gt_materials INTO ls_material. + APPEND ls_material TO lt_materials_sorted. + ENDLOOP. + + SORT lt_materials_sorted BY level ASCENDING matnr ASCENDING. + + "Write: / 'Verarbeite', lines( lt_materials_sorted ), 'Materialien in hierarchischer Reihenfolge'. + + LOOP AT lt_materials_sorted INTO ls_material. + IF sy-tabix <= 10. + "Write: / ' Material:', ls_material-matnr, 'Level:', ls_material-level. + ENDIF. + ENDLOOP. + + " ======================================== + " SCHRITT 5: VERARBEITE HIERARCHISCH + BEFÜLLE gt_results + " ======================================== + + + + + + + + + DO 10 TIMES. + lv_iteration = sy-index. + lv_changed = abap_false. + + "Write: / ''. + "Write: / '──── Iteration', lv_iteration, '────'. + + DATA lv_change_count TYPE i VALUE 0. + + LOOP AT lt_materials_sorted INTO ls_material. + " Hole aktuellen Code + READ TABLE gt_materials WITH KEY matnr = ls_material-matnr + INTO DATA(ls_current). + + " Speichere alten Code + + + + + + DATA(lv_old_code) = ls_current-zzlzcod. + + + " Berechne neuen Code + DATA(lv_new_code) = me->apply_dominance_rules( iv_matnr = ls_material-matnr ). + + " Prüfe ob Änderung + + + + + + + IF lv_new_code <> ls_current-zzlzcod. + ls_current-zzlzcod = lv_new_code. + MODIFY TABLE gt_materials FROM ls_current. + lv_changed = abap_true. + ADD 1 TO lv_change_count. + ENDIF. + + " *** NEU: BEFÜLLE gt_results *** + DATA(ls_result) = VALUE ty_result( + matnr = ls_material-matnr + old_code = lv_old_code + new_code = lv_new_code + changed = COND #( WHEN lv_new_code <> lv_old_code THEN 'X' ELSE '-' ) + message = '' + ). + + " Prüfe ob bereits in gt_results + READ TABLE gt_results WITH KEY matnr = ls_material-matnr + TRANSPORTING NO FIELDS. + + IF sy-subrc = 0. + + + MODIFY TABLE gt_results FROM ls_result. + + ELSE. + + INSERT ls_result INTO TABLE gt_results. + ENDIF. + + ENDLOOP. + + "Write: / 'Iteration', lv_iteration, ':', lv_change_count, 'Änderungen'. + + IF lv_changed = abap_false. + "Write: / '*** Keine Änderungen mehr - Vererbung abgeschlossen ***'. + EXIT. + ENDIF. + ENDDO. + + "Write: / '════════════════════════════════════════════════'. + + " ======================================== + " SCHRITT 6: DEBUG - ZEIGE gt_results + " ======================================== + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ gt_results BEFÜLLT ║'. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + "Write: / 'Anzahl Einträge in gt_results:', lines( gt_results ). + + DATA lv_changed_count TYPE i VALUE 0. + LOOP AT gt_results INTO ls_result WHERE changed = 'X'. + ADD 1 TO lv_changed_count. + ENDLOOP. + + "Write: / 'Davon geändert:', lv_changed_count. + + + + + + + + + + + + + + + + + + + ENDMETHOD. + + + + + + + METHOD apply_dominance_rules. + DATA: lt_parents TYPE tt_usage_std, + lv_has_a TYPE abap_bool, + lv_has_e TYPE abap_bool, + lv_has_n TYPE abap_bool, + lv_has_s TYPE abap_bool, + lv_count_a TYPE i, + lv_count_e TYPE i, + lv_count_n TYPE i, + lv_position_4 TYPE char1 VALUE '0', + lv_total_verbrauch TYPE p DECIMALS 2, + lv_a_verbrauch TYPE p DECIMALS 2, + lv_anteil TYPE p DECIMALS 2, + lv_parent_verbrauch TYPE p DECIMALS 2, + lv_effektive_menge TYPE p DECIMALS 3, + lv_mara_code TYPE char4, + lv_parent_count TYPE i, + lv_all_n0x0 TYPE abap_bool VALUE abap_true, + lv_parent_count_debug TYPE i. + + " ======================================== + " DEBUG: START + " ======================================== + "Write: / ''. + "Write: / '╔══════════════════════════════════════════════════╗'. + "Write: / '║ apply_dominance_rules für Material:', iv_matnr. + "Write: / '╚══════════════════════════════════════════════════╝'. + + " ======================================== + " SCHRITT 1: HOLE AKTUELLES MATERIAL + " ======================================== + READ TABLE gt_materials WITH KEY matnr = iv_matnr + INTO DATA(ls_current_mat). + IF sy-subrc <> 0. + "Write: / '*** FEHLER: Material nicht in gt_materials gefunden!'. + rv_code = 'N0X0'. + RETURN. + ENDIF. + + "Write: / ' Aktueller Code:', ls_current_mat-zzlzcod. + "Write: / ' Level:', ls_current_mat-level. + + " Position 4 Behandlung + IF strlen( ls_current_mat-zzlzcod ) >= 4. + lv_position_4 = ls_current_mat-zzlzcod+3(1). + ENDIF. + "Write: / ' Position 4:', lv_position_4. + + " ======================================== + " SCHRITT 2: ZZZZ-SPEZIALREGEL + " ======================================== + IF ls_current_mat-zzlzcod = 'ZZZZ'. + SELECT SINGLE zzlzcod FROM mara + INTO @lv_mara_code + WHERE matnr = @iv_matnr. + IF lv_mara_code = 'ZZZZ'. + "Write: / ' -> ZZZZ-Material (unveränderbar)'. + rv_code = 'ZZZZ'. + RETURN. + ENDIF. + ENDIF. + + " ======================================== + " SCHRITT 3: N0X1 SPEZIALFALL + " ======================================== + IF ls_current_mat-zzlzcod = 'N0X1'. + LOOP AT gt_bom_relations INTO DATA(ls_rel_check) + WHERE child = iv_matnr. + ADD 1 TO lv_parent_count. + ENDLOOP. + + IF lv_parent_count = 1. + READ TABLE gt_bom_relations INTO ls_rel_check + WITH KEY child = iv_matnr. + IF sy-subrc = 0. + READ TABLE gt_materials INTO DATA(ls_single_parent) + WITH KEY matnr = ls_rel_check-parent. + IF sy-subrc = 0 AND ls_single_parent-zzlzcod(1) = 'A'. + "Write: / ' -> N0X1 Spezialfall: Bleibt N0X1'. + rv_code = 'N0X1'. + RETURN. + ENDIF. + ENDIF. + ENDIF. + ENDIF. + + " ======================================== + " SCHRITT 4: ZÄHLE PARENTS (DEBUG) + " ======================================== + CLEAR lv_parent_count_debug. + LOOP AT gt_bom_relations INTO DATA(ls_rel_debug) + WHERE child = iv_matnr. + ADD 1 TO lv_parent_count_debug. + ENDLOOP. + + "Write: / ' Anzahl Parents in gt_bom_relations:', lv_parent_count_debug. + + + "INS 18.1.26 + IF lv_parent_count_debug = 0. + " *** TOP-MATERIAL (keine Parents) → NICHT ANFASSEN *** + " Defensive Variante: Prüft alle Fälle explizit + + IF ls_current_mat-zzlzcod = 'ZZZZ'. + " ZZZZ bleibt ZZZZ + rv_code = 'ZZZZ'. + ELSEIF ls_current_mat-zzlzcod IS INITIAL. + " Leerer Code bleibt leer + rv_code = ''. + ELSE. + " Vorhandener Code bleibt unverändert + rv_code = ls_current_mat-zzlzcod. + ENDIF. + + RETURN. + ENDIF. + + + " DEL 18.1.26 +* IF lv_parent_count_debug = 0. +* "Write: / ' *** KEINE PARENTS GEFUNDEN! ***'. +* "Write: / ' Material behält Code:', ls_current_mat-zzlzcod. +* +* IF ls_current_mat-zzlzcod IS NOT INITIAL AND +* ls_current_mat-zzlzcod <> 'ZZZZ'. +* rv_code = ls_current_mat-zzlzcod. +* ELSE. +* rv_code = 'N0X0'. +* ENDIF. +* +* "Write: / ' Rückgabe:', rv_code. +* "Write: / '╚══════════════════════════════════════════════════╝'. +* RETURN. +* ENDIF. + + " ======================================== + " SCHRITT 5: SAMMLE ALLE PARENTS + " ======================================== + "Write: / ' Sammle Parents:'. + + LOOP AT gt_bom_relations INTO DATA(ls_relation) + WHERE child = iv_matnr. + + READ TABLE gt_materials WITH KEY matnr = ls_relation-parent + INTO DATA(ls_parent). + + IF sy-subrc = 0. + "Write: / ' Parent:', ls_relation-parent, + "'Code:', ls_parent-zzlzcod, + "'Menge:', ls_relation-menge. + + IF ls_parent-zzlzcod = 'ZZZZ'. + "Write: ' -> ZZZZ übersprungen'. + CONTINUE. + ENDIF. + + " *** KORREKTUR: Verwende Default-Code statt zu überspringen *** + IF ls_parent-zzlzcod IS INITIAL. + "Write: ' -> Code ist leer, übersprungen'. + CONTINUE. " ❌ ÜBERSPRINGT PARENT! + ENDIF. + + + + APPEND VALUE #( matnr = iv_matnr + parent = ls_relation-parent + parent_code = ls_parent-zzlzcod + usage_qty = ls_relation-menge ) TO lt_parents. + + " Verbrauchslogik mit Fallbacks + READ TABLE gt_consumption WITH KEY matnr = ls_relation-parent + INTO DATA(ls_consumption). + + IF sy-subrc = 0 AND ls_consumption-gsv_korr > 0. + lv_parent_verbrauch = ls_consumption-gsv_korr. + lv_effektive_menge = ls_relation-menge. + "Write: ' -> Verbrauch:', lv_parent_verbrauch. + + ELSEIF sy-subrc = 0 AND ls_consumption-gsv_korr = 0. + lv_parent_verbrauch = 1. + lv_effektive_menge = ls_relation-menge. + "Write: ' -> Verbrauch=0, verwende Menge'. + + ELSE. + lv_parent_verbrauch = 1. + IF ls_relation-menge > 0. + lv_effektive_menge = ls_relation-menge. + ELSE. + lv_effektive_menge = 1. + ENDIF. + "Write: ' -> Kein Verbrauch, Fallback'. + ENDIF. + + " Gesamtverbrauch akkumulieren + lv_total_verbrauch = lv_total_verbrauch + + ( lv_effektive_menge * lv_parent_verbrauch ). + + " A-Anteil berechnen + CASE ls_parent-zzlzcod+0(1). + WHEN 'A'. + lv_has_a = abap_true. + ADD 1 TO lv_count_a. + + DATA: lv_percent_digit TYPE c, + lv_mittlerer_prozent TYPE p DECIMALS 2. + lv_percent_digit = ls_parent-zzlzcod+1(1). + + IF lv_percent_digit = '0' AND ls_parent-zzlzcod+2(1) = 'X'. + lv_a_verbrauch = lv_a_verbrauch + + ( lv_effektive_menge * lv_parent_verbrauch ). + "Write: ' -> A0X: 100% A-Anteil'. + + ELSEIF lv_percent_digit BETWEEN '1' AND '9'. + CASE lv_percent_digit. + WHEN '0'. lv_mittlerer_prozent = '2'. " 0-4% → 2% + WHEN '1'. lv_mittlerer_prozent = '9.5'. " 5-14% → 9.5% ✅ + WHEN '2'. lv_mittlerer_prozent = '19.5'. " 15-24% → 19.5% ✅ + WHEN '3'. lv_mittlerer_prozent = '29.5'. " 25-34% → 29.5% ✅ + WHEN '4'. lv_mittlerer_prozent = '39.5'. " 35-44% → 39.5% ✅ + WHEN '5'. lv_mittlerer_prozent = '49.5'. " 45-54% → 49.5% ✅ + WHEN '6'. lv_mittlerer_prozent = '59.5'. " 55-64% → 59.5% ✅ + WHEN '7'. lv_mittlerer_prozent = '69.5'. " 65-74% → 69.5% ✅ + WHEN '8'. lv_mittlerer_prozent = '79.5'. " 75-84% → 79.5% ✅ + WHEN '9'. lv_mittlerer_prozent = '92'. " 85-99% → 92% ✅ + ENDCASE. + + + + + + + + + + + + + + + + + lv_a_verbrauch = lv_a_verbrauch + + ( lv_effektive_menge * lv_parent_verbrauch * + lv_mittlerer_prozent / 100 ). + "Write: ' -> A-Anteil:', lv_mittlerer_prozent, '%'. + + ELSEIF lv_percent_digit = '0' AND ls_parent-zzlzcod+2(1) = 'T'. + lv_a_verbrauch = lv_a_verbrauch + + ( lv_effektive_menge * lv_parent_verbrauch * 2 / 100 ). + "Write: ' -> A0T: 2% A-Anteil'. + ENDIF. + + WHEN 'E'. + lv_has_e = abap_true. + ADD 1 TO lv_count_e. + "Write: ' -> E-Code'. + + WHEN 'N'. + lv_has_n = abap_true. + ADD 1 TO lv_count_n. + "Write: ' -> N-Code'. + + WHEN 'S'. + lv_has_s = abap_true. + "Write: ' -> S-Code'. + ENDCASE. + + ELSE. + "Write: / ' Parent:', ls_relation-parent, '-> NICHT IN gt_materials!'. + ENDIF. + ENDLOOP. + + "Write: / ' Gültige Parents gesammelt:', lines( lt_parents ). + "Write: / ' Counts: A=', lv_count_a, 'E=', lv_count_e, 'N=', lv_count_n. + + " ======================================== + " SCHRITT 6: FEHLERFALL - KEINE GÜLTIGEN PARENTS + " ======================================== + IF lines( lt_parents ) = 0. + "Write: / ' *** KEINE GÜLTIGEN PARENTS! ***'. + + IF ls_current_mat-zzlzcod IS NOT INITIAL AND + ls_current_mat-zzlzcod <> 'ZZZZ'. + rv_code = ls_current_mat-zzlzcod. + ELSE. + rv_code = 'N0X0'. + ENDIF. + + "Write: / ' Rückgabe:', rv_code. + "Write: / '╚══════════════════════════════════════════════════╝'. + RETURN. + ENDIF. + + " ======================================== + " SCHRITT 7: BERECHNE A-ANTEIL + " ======================================== + IF lv_total_verbrauch > 0. + lv_anteil = ( lv_a_verbrauch / lv_total_verbrauch ) * 100. + ELSE. + lv_anteil = 0. + ENDIF. + + "Write: / ' Total-Verbrauch:', lv_total_verbrauch. + "Write: / ' A-Verbrauch:', lv_a_verbrauch. + "Write: / ' A-Anteil:', lv_anteil, '%'. + + + + + + + + " ======================================== + " SCHRITT 8: VERERBUNGSLOGIK (KORRIGIERT) + " ======================================== + + " REGEL 1: E dominiert (nur wenn KEIN N dabei ist) + IF lv_has_e = abap_true AND lv_has_n = abap_false. + "Write: / ' REGEL 1: E dominiert (ohne N)'. + rv_code = 'E0X' && lv_position_4. + + " REGEL 2: N + A (ohne E) → Auslauf-Teilmenge + ELSEIF lv_has_n = abap_true AND lv_has_a = abap_true AND lv_has_e = abap_false. + "Write: / ' REGEL 2: N + A (ohne E) = Auslauf-Teilmenge'. + + IF lv_anteil = 100. + rv_code = 'A0X' && lv_position_4. + ELSEIF lv_anteil >= 85. + rv_code = 'A9T' && lv_position_4. + ELSEIF lv_anteil >= 75. + rv_code = 'A8T' && lv_position_4. + ELSEIF lv_anteil >= 65. + rv_code = 'A7T' && lv_position_4. + ELSEIF lv_anteil >= 55. + rv_code = 'A6T' && lv_position_4. + ELSEIF lv_anteil >= 45. + rv_code = 'A5T' && lv_position_4. + ELSEIF lv_anteil >= 35. + rv_code = 'A4T' && lv_position_4. + ELSEIF lv_anteil >= 25. + rv_code = 'A3T' && lv_position_4. + ELSEIF lv_anteil >= 15. + rv_code = 'A2T' && lv_position_4. + ELSEIF lv_anteil >= 5. + rv_code = 'A1T' && lv_position_4. + ELSEIF lv_anteil > 0. + rv_code = 'A0T' && lv_position_4. + ELSE. + rv_code = 'N0X' && lv_position_4. + ENDIF. + + " REGEL 3: N dominiert IMMER (auch mit E/A) + ELSEIF lv_has_n = abap_true. + "Write: / ' REGEL 3: N dominiert (mit E/A)'. + rv_code = 'N0X' && lv_position_4. + + " REGEL 4: Nur A (ohne N/E) + ELSEIF lv_has_a = abap_true AND lv_has_n = abap_false AND lv_has_e = abap_false. + "Write: / ' REGEL 4: Nur A'. + + IF lv_anteil = 100. + rv_code = 'A0X' && lv_position_4. + ELSEIF lv_anteil >= 85. + rv_code = 'A9T' && lv_position_4. + ELSEIF lv_anteil >= 75. + rv_code = 'A8T' && lv_position_4. + ELSEIF lv_anteil >= 65. + rv_code = 'A7T' && lv_position_4. + ELSEIF lv_anteil >= 55. + rv_code = 'A6T' && lv_position_4. + ELSEIF lv_anteil >= 45. + rv_code = 'A5T' && lv_position_4. + ELSEIF lv_anteil >= 35. + rv_code = 'A4T' && lv_position_4. + ELSEIF lv_anteil >= 25. + rv_code = 'A3T' && lv_position_4. + ELSEIF lv_anteil >= 15. + rv_code = 'A2T' && lv_position_4. + ELSEIF lv_anteil >= 5. + rv_code = 'A1T' && lv_position_4. + ELSEIF lv_anteil > 0. + rv_code = 'A0T' && lv_position_4. + ELSE. + rv_code = 'N0X' && lv_position_4. + ENDIF. + + " REGEL 5: Fallback + ELSE. + "Write: / ' REGEL 5: Fallback (keine gültigen Parents)'. + rv_code = 'N0X' && lv_position_4. + ENDIF. + + + ENDMETHOD. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + METHOD Write_to_journal. + DATA: ls_journal TYPE zlzcod_journl. + + ls_journal-mandt = sy-mandt. + ls_journal-matnr = iv_matnr. + ls_journal-aenderungsdatum = sy-datum. + ls_journal-aenderungszeit = sy-uzeit. + ls_journal-aenderungsuser = sy-uname. + ls_journal-old_lzcod = iv_old_lzcod. + ls_journal-new_lzcod = iv_new_lzcod. + ls_journal-old_lzcodsort = iv_old_lzcodsort. + ls_journal-new_lzcodsort = iv_new_lzcodsort. + ls_journal-programm = sy-repid. + + " TOP-Material aus aktueller Selektion (da Wrapper nur 1 übergibt) + READ TABLE s_matnr INDEX 1 INTO DATA(ls_sel). + IF sy-subrc = 0 AND ls_sel-sign = 'I' AND ls_sel-option = 'EQ'. + ls_journal-top_matnr = ls_sel-low. + ELSE. + ls_journal-top_matnr = iv_matnr. " Fallback + ENDIF. + + " In Tabelle schreiben + INSERT zlzcod_journl FROM ls_journal. + + IF sy-subrc <> 0. + " Bei Fehler: Update statt Insert (falls Eintrag schon existiert) + UPDATE zlzcod_journl FROM ls_journal. + ENDIF. + + " Für Debug-Zwecke: + IF p_test = abap_true. + ""Write: / 'Journal:', ls_journal-matnr, + "'TOP:', ls_journal-top_matnr, + "'LZCOD:', ls_journal-old_lzcod, '->', ls_journal-new_lzcod, + " 'LZCODSORT:', ls_journal-old_lzcodsort, '->', ls_journal-new_lzcodsort. + ENDIF. + ENDMETHOD. + + +* +* METHOD update_database. +* DATA: lt_update_batch TYPE STANDARD TABLE OF ty_result, +* lv_batch_count TYPE i, +* lv_updated TYPE i, +* lv_datum_str TYPE dats. +* +* lv_datum_str = sy-datum. +* +* " Batch-Update für Performance +* LOOP AT gt_results INTO DATA(ls_result) WHERE changed = abap_true. +* APPEND ls_result TO lt_update_batch. +* +* +* " Führe Batch-Update durch +* LOOP AT lt_update_batch INTO DATA(ls_update). +* +* UPDATE mara SET zzlzcod = @ls_update-new_code, +* zzlzdat = @lv_datum_str +* WHERE matnr = @ls_update-matnr. +* COMMIT WORK. +* +* " Verwende MM02 für Update +* +* " Journal-Eintrag mit TOP-Material +** me->""Write_to_journal( +** iv_matnr = ls_update-matnr +** iv_old_lzcod = ls_update-old_code +** iv_new_lzcod = ls_update-new_code ). +* +* ADD 1 TO lv_updated. +* ENDLOOP. +* +* COMMIT WORK. +* CLEAR lt_update_batch. +* ADD 1 TO lv_batch_count. +* +* " Progress-Anzeige +* cl_progress_indicator=>progress_indicate( +* i_text = |Batch { lv_batch_count } aktualisiert| +* i_processed = lv_updated +* i_total = lines( gt_results ) ). +* +* ENDLOOP. +* +* " Letzter Batch +* IF lines( lt_update_batch ) > 0. +* LOOP AT lt_update_batch INTO ls_update. +* IF p_mm = abap_true. +* UPDATE mara SET zzlzcod = @ls_update-new_code, +* zzlzdat = @lv_datum_str +* WHERE matnr = @ls_update-matnr. COMMIT WORK. +* ELSE. +* +* +* +* +* ENDIF. +* +* " Journal-Eintrag mit TOP-Material +* me->Write_to_journal( +* iv_matnr = ls_update-matnr +* iv_old_lzcod = ls_update-old_code +* iv_new_lzcod = ls_update-new_code ). +* +* ADD 1 TO lv_updated. +* ENDLOOP. +* COMMIT WORK. +* ENDIF. +* +* ""Write: / |{ lv_updated } Materialien über MM02 aktualisiert (ZZLZCOD)|. +* ENDMETHOD. + + METHOD update_database. + DATA: lv_updated TYPE i, + lv_datum_str TYPE dats. + + lv_datum_str = sy-datum. + + LOOP AT gt_results INTO DATA(ls_result) WHERE changed = abap_true. + + " Update durchführen + UPDATE mara SET zzlzcod = @ls_result-new_code, + zzlzdat = @lv_datum_str + WHERE matnr = @ls_result-matnr. + COMMIT WORK. + + " Journal-Eintrag + me->write_to_journal( + iv_matnr = ls_result-matnr + iv_old_lzcod = ls_result-old_code + iv_new_lzcod = ls_result-new_code ). + + ADD 1 TO lv_updated. + + ENDLOOP. + + WRITE: / lv_updated, 'Materialien aktualisiert (ZZLZCOD)'. + + ENDMETHOD. + + + + + METHOD update_database_sortiment. + DATA: lv_updated TYPE i, + lv_errors TYPE i, + lv_status99 TYPE i, + lv_datum_str TYPE dats. + + lv_datum_str = sy-datum. + + WRITE: / '=================================================='. + WRITE: / 'START: Update Database Sortiment'. + WRITE: / 'Anzahl zu aktualisieren:', lines( gt_results ). + + LOOP AT gt_results INTO DATA(ls_result) + WHERE changed_sort = abap_true. + + " *** DIREKTES UPDATE MARA (auch leerer Code für Status 99) *** + UPDATE mara + SET zzlzcodsort = @ls_result-new_code_sort, + zzlzdat = @lv_datum_str + WHERE matnr = @ls_result-matnr. + + IF sy-subrc = 0. + ADD 1 TO lv_updated. + + " Zähle Status 99 separat + IF ls_result-new_code_sort IS INITIAL. + ADD 1 TO lv_status99. + ENDIF. + + " Journal-Eintrag + me->write_to_journal( + iv_matnr = ls_result-matnr + iv_old_lzcod = ls_result-old_code + iv_new_lzcod = ls_result-old_code + iv_old_lzcodsort = ls_result-old_code_sort + iv_new_lzcodsort = ls_result-new_code_sort ). + + ELSE. + ADD 1 TO lv_errors. + WRITE: / 'ERROR: Update fehlgeschlagen für', ls_result-matnr. + ENDIF. + ENDLOOP. + + " Commit nach allen Updates + IF lv_updated > 0. + COMMIT WORK AND WAIT. + WRITE: / 'ERFOLG:', lv_updated, 'Sortiments-Codes aktualisiert'. + WRITE: / ' Davon Status 99 (geleert):', lv_status99. + ENDIF. + + IF lv_errors > 0. + WRITE: / 'FEHLER:', lv_errors, 'Updates fehlgeschlagen'. + ENDIF. + + WRITE: / 'ENDE: Update Database Sortiment'. + WRITE: / '=================================================='. + ENDMETHOD. + + + +* +* METHOD transaktion_mm02. +* DATA: lv_matnr_str TYPE bdc_fval, " Geändert zu bdc_fval +* lv_datum_str TYPE bdc_fval, " Geändert zu bdc_fval +* lv_lzcod_str TYPE bdc_fval, " NEU für iv_lzcod +* lv_lzcodsort_str TYPE bdc_fval. " NEU für iv_lzcodsort +* +* CLEAR: gt_bdcdata, gt_bdcmsg. +* +* " Material und Datum formatieren - als bdc_fval +* lv_matnr_str = |{ iv_matnr ALPHA = OUT }|. +* lv_datum_str = |{ sy-datum DATE = USER }|. +* lv_lzcod_str = iv_lzcod. " Direkte Zuweisung, da char4 -> bdc_fval kompatibel +* +* " Optional: ZZLZCODSORT konvertieren +* IF iv_lzcodsort IS NOT INITIAL. +* lv_lzcodsort_str = iv_lzcodsort. +* ENDIF. +* +* " Einstiegsbild MM02 +* me->bdc_dynpro( iv_program = 'SAPLMGMM' iv_dynpro = '0060' ). +* me->bdc_field( iv_fnam = 'BDC_OKCODE' iv_fval = 'AUSW' ). +* me->bdc_field( iv_fnam = 'RMMG1-MATNR' iv_fval = lv_matnr_str ). +* +* " Sichtenauswahl +* me->bdc_dynpro( iv_program = 'SAPLMGMM' iv_dynpro = '0070' ). +* me->bdc_field( iv_fnam = 'BDC_OKCODE' iv_fval = '=ENTR' ). +* me->bdc_field( iv_fnam = 'MSICHTAUSW-KZSEL(01)' iv_fval = 'X' ). +* +* " Grunddaten - beide Felder setzen +* me->bdc_dynpro( iv_program = 'SAPLMGMM' iv_dynpro = '4004' ). +* me->bdc_field( iv_fnam = 'BDC_OKCODE' iv_fval = '/11' ). +* +* " ZZLZCOD setzen +* me->bdc_field( iv_fnam = 'MARA-ZZLZCOD' iv_fval = lv_lzcod_str ). +* me->bdc_field( iv_fnam = 'MARA-ZZLZDAT' iv_fval = lv_datum_str ). +* +* " ZZLZCODSORT setzen falls übergeben +* IF iv_lzcodsort IS NOT INITIAL. +* me->bdc_field( iv_fnam = 'MARA-ZZLZCODSORT' iv_fval = lv_lzcodsort_str ). +* ENDIF. +* +* me->bdc_transaction( iv_tcode = 'MM02' ). +* ENDMETHOD. +* + + + + + + + + + + + + + + + + + + + + + METHOD is_debug_material. + " NUR diese 4 Materialien debuggen! + rv_debug = COND #( + WHEN iv_matnr = 'B53618' OR + iv_matnr = 'B56383' OR + iv_matnr = 'B58383' OR + iv_matnr = 'B69327' + THEN abap_true + ELSE abap_false + ). + ENDMETHOD. + + METHOD debug_problem_material. + DATA: lt_info TYPE string_table, + lv_line TYPE string. + + " Nur debuggen wenn es ein Problem-Material ist + IF is_debug_material( iv_matnr ) = abap_false. + RETURN. + ENDIF. + + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ DEBUG PROBLEM-MATERIAL:', iv_matnr, 'Phase:', iv_phase. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + + " Je nach Phase verschiedene Informationen sammeln + CASE iv_phase. + WHEN 'LOAD'. + " Nach dem Laden: Zeige Stammdaten + READ TABLE gt_materials WITH KEY matnr = iv_matnr + INTO DATA(ls_mat). + IF sy-subrc = 0. + "Write: / ' Stammdaten gefunden:'. + "Write: / ' MTART:', ls_mat-mtart. + "Write: / ' ZZLZCOD (aktuell):', ls_mat-zzlzcod. + "Write: / ' ZZLZCODSORT:', ls_mat-zzlzcodsort. + "Write: / ' DISST:', ls_mat-disst. + ELSE. + "Write: / ' *** FEHLER: Material NICHT in gt_materials!'. + ENDIF. + + WHEN 'RELATIONS'. + " Parent-Child Beziehungen + DATA: lv_parent_count TYPE i, + lv_child_count TYPE i. + + "Write: / ' === PARENT-BEZIEHUNGEN ==='. + LOOP AT gt_bom_relations INTO DATA(ls_rel) + WHERE child = iv_matnr. + ADD 1 TO lv_parent_count. + + READ TABLE gt_materials WITH KEY matnr = ls_rel-parent + INTO DATA(ls_parent). + IF sy-subrc = 0. + "Write: / ' Parent', lv_parent_count, ':', ls_rel-parent. + "Write: / ' Code:', ls_parent-zzlzcod. + "Write: / ' Menge:', ls_rel-menge. + ELSE. + "Write: / ' Parent', lv_parent_count, ':', ls_rel-parent, + "'*** NICHT in gt_materials!'. + ENDIF. + ENDLOOP. + + IF lv_parent_count = 0. + "Write: / ' *** KEINE PARENTS GEFUNDEN! ***'. + ELSE. + "Write: / ' Anzahl Parents:', lv_parent_count. + ENDIF. + + "Write: / ' === CHILD-BEZIEHUNGEN ==='. + LOOP AT gt_bom_relations INTO ls_rel + WHERE parent = iv_matnr. + ADD 1 TO lv_child_count. + "Write: / ' Child:', ls_rel-child, 'Menge:', ls_rel-menge. + ENDLOOP. + + IF lv_child_count = 0. + "Write: / ' Keine Children (Endprodukt oder Komponente ohne Stückliste)'. + ELSE. + "Write: / ' Anzahl Children:', lv_child_count. + ENDIF. + + WHEN 'CONSUMPTION'. + " Verbrauchsdaten + "Write: / ' === VERBRAUCHSDATEN ==='. + + " Eigener Verbrauch + READ TABLE gt_consumption WITH KEY matnr = iv_matnr + INTO DATA(ls_cons). + IF sy-subrc = 0. + "Write: / ' Eigener Verbrauch:'. + "Write: / ' gsv01:', ls_cons-gsv01. + "Write: / ' gsv_korr:', ls_cons-gsv_korr. + ELSE. + "Write: / ' *** KEIN Verbrauch gefunden!'. + ENDIF. + + " Parent-Verbräuche + "Write: / ' Parent-Verbräuche:'. + LOOP AT gt_bom_relations INTO ls_rel WHERE child = iv_matnr. + READ TABLE gt_consumption WITH KEY matnr = ls_rel-parent + INTO ls_cons. + IF sy-subrc = 0. + "Write: / ' Parent', ls_rel-parent, + "'gsv01:', ls_cons-gsv01, + "'gsv_korr:', ls_cons-gsv_korr. + ELSE. + "Write: / ' Parent', ls_rel-parent, 'KEIN Verbrauch'. + ENDIF. + ENDLOOP. + + WHEN 'BEFORE_CALC'. + " Vor der Berechnung + "Write: / ' === VOR BERECHNUNG ==='. + READ TABLE gt_materials WITH KEY matnr = iv_matnr INTO ls_mat. + IF sy-subrc = 0. + "Write: / ' Aktueller Code:', ls_mat-zzlzcod. + + " Zeige Parent-Codes + "Write: / ' Parent-Codes:'. + LOOP AT gt_bom_relations INTO ls_rel WHERE child = iv_matnr. + READ TABLE gt_materials WITH KEY matnr = ls_rel-parent + INTO ls_parent. + IF sy-subrc = 0. + "Write: / ' ', ls_rel-parent, '=', ls_parent-zzlzcod. + ELSE. + "Write: / ' ', ls_rel-parent, '= NICHT GEFUNDEN'. + ENDIF. + ENDLOOP. + ENDIF. + + WHEN 'AFTER_CALC'. + " Nach der Berechnung + "Write: / ' === NACH BERECHNUNG ==='. + READ TABLE gt_results WITH KEY matnr = iv_matnr + INTO DATA(ls_result). + IF sy-subrc = 0. + "Write: / ' Alter Code:', ls_result-old_code. + "Write: / ' Neuer Code:', ls_result-new_code. + "Write: / ' Geändert:', ls_result-changed. + ELSE. + "Write: / ' *** KEIN ERGEBNIS in gt_results!'. + ENDIF. + + ENDCASE. + + "Write: / '══════════════════════════════════════════════════════════'. + ENDMETHOD. + + METHOD build_hierarchy_from_stb. + DATA: lt_vc_data TYPE STANDARD TABLE OF zpowerbi_vc, + ls_bom_rel TYPE ty_bom_relation, + ls_material TYPE ty_material, + lv_top_material TYPE matnr, + lv_rel_count TYPE i, + lv_mat_count TYPE i. + + " ======================================== + " SCHRITT 1: TOP-Material bestimmen + " ======================================== + IF iv_top_material IS NOT INITIAL. + lv_top_material = iv_top_material. + ELSE. + RETURN. + ENDIF. + + " ======================================== + " SCHRITT 2: Prüfe ob Material in ZPOWERBI_VC existiert + " ======================================== + SELECT COUNT(*) FROM zpowerbi_vc + INTO @DATA(lv_row_count) + WHERE matnr = @lv_top_material. + + IF lv_row_count = 0. + RETURN. + ENDIF. + + " ======================================== + " SCHRITT 3: Lade Hierarchie aus ZPOWERBI_VC + " ======================================== + SELECT * FROM zpowerbi_vc + INTO TABLE @lt_vc_data + WHERE matnr = @lv_top_material + ORDER BY mat_mstav. + + IF sy-subrc <> 0. + RETURN. + ENDIF. + + " ======================================== + " SCHRITT 4: TOP-Material zu gt_materials hinzufügen + " ======================================== + READ TABLE gt_materials WITH KEY matnr = lv_top_material + TRANSPORTING NO FIELDS. + IF sy-subrc <> 0. + SELECT SINGLE matnr, mtart, zzlzcod, zzlzcodsort, zztyp_f4, pstat, disst, mstae + FROM mara + INTO CORRESPONDING FIELDS OF @ls_material + WHERE matnr = @lv_top_material. + IF sy-subrc = 0. + " *** NEU: Status 99 prüfen *** + IF ls_material-mstae = '99'. + ls_material-zzlzcodsort = ''. + ENDIF. + INSERT ls_material INTO TABLE gt_materials. + ADD 1 TO lv_mat_count. + ENDIF. + ENDIF. + + " ======================================== + " SCHRITT 5: Verarbeite ZPOWERBI_VC-Einträge + " ======================================== + LOOP AT lt_vc_data INTO DATA(ls_vc). + + DATA(lv_parent_matnr) = VALUE matnr( ). + + IF ls_vc-mat_mstae = 0. + lv_parent_matnr = lv_top_material. + ELSE. + lv_parent_matnr = ls_vc-kom_mstae. + ENDIF. + + IF lv_parent_matnr IS NOT INITIAL. + ls_bom_rel-parent = lv_parent_matnr. + ls_bom_rel-child = ls_vc-kompnr. + ls_bom_rel-menge = ls_vc-menge. + ls_bom_rel-level = ls_vc-stufe. + + INSERT ls_bom_rel INTO TABLE gt_bom_relations. + ADD 1 TO lv_rel_count. + ENDIF. + + " Füge Child-Material zu gt_materials hinzu + READ TABLE gt_materials WITH KEY matnr = ls_vc-kompnr + TRANSPORTING NO FIELDS. + IF sy-subrc <> 0. + CLEAR ls_material. + ls_material-matnr = ls_vc-kompnr. + ls_material-mtart = ls_vc-materialart. + ls_material-disst = ''. + + " Hole ZZLZCOD/ZZLZCODSORT/MSTAE aus MARA + SELECT SINGLE zzlzcod, zzlzcodsort, zztyp_f4, pstat, mstae + FROM mara + INTO CORRESPONDING FIELDS OF @ls_material + WHERE matnr = @ls_vc-kompnr. + + IF sy-subrc = 0. + " *** NEU: Status 99 prüfen *** + IF ls_material-mstae = '99'. + ls_material-zzlzcodsort = ''. + ENDIF. + INSERT ls_material INTO TABLE gt_materials. + ADD 1 TO lv_mat_count. + ENDIF. + ENDIF. + ENDLOOP. + + ENDMETHOD. + + METHOD debug_collect_info. + DATA: lv_info TYPE string. + + CLEAR rt_info. + + " Sammle alle Informationen für ein Material + READ TABLE gt_materials WITH KEY matnr = iv_matnr INTO DATA(ls_mat). + IF sy-subrc = 0. + lv_info = |Material: { iv_matnr } Code: { ls_mat-zzlzcod }|. + APPEND lv_info TO rt_info. + ENDIF. + + " Parents + LOOP AT gt_bom_relations INTO DATA(ls_rel) WHERE child = iv_matnr. + READ TABLE gt_materials WITH KEY matnr = ls_rel-parent + INTO DATA(ls_parent). + IF sy-subrc = 0. + lv_info = | Parent: { ls_rel-parent } Code: { ls_parent-zzlzcod }|. + ELSE. + lv_info = | Parent: { ls_rel-parent } NICHT GEFUNDEN|. + ENDIF. + APPEND lv_info TO rt_info. + ENDLOOP. + ENDMETHOD. + + + + + + + + + + + + + + + METHOD bdc_dynpro. + DATA: ls_bdcdata TYPE bdcdata. + + ls_bdcdata-program = iv_program. + ls_bdcdata-dynpro = iv_dynpro. + ls_bdcdata-dynbegin = 'X'. + APPEND ls_bdcdata TO gt_bdcdata. + ENDMETHOD. + + METHOD bdc_field. + DATA: ls_bdcdata TYPE bdcdata. + + ls_bdcdata-fnam = iv_fnam. + ls_bdcdata-fval = iv_fval. + APPEND ls_bdcdata TO gt_bdcdata. + ENDMETHOD. + + METHOD bdc_transaction. + DATA: ls_options TYPE ctu_params. + + ls_options-dismode = 'N'. " Kein Bildschirm + ls_options-updmode = 'S'. " Synchron + ls_options-defsize = 'X'. " Standardgröße + + CALL TRANSACTION iv_tcode + USING gt_bdcdata + OPTIONS FROM ls_options + MESSAGES INTO gt_bdcmsg. + + " Fehlerbehandlung + LOOP AT gt_bdcmsg INTO DATA(ls_msg) + WHERE msgtyp = 'E' OR msgtyp = 'A'. + MESSAGE ID ls_msg-msgid TYPE 'I' NUMBER ls_msg-msgnr + WITH ls_msg-msgv1 ls_msg-msgv2 ls_msg-msgv3 ls_msg-msgv4. + ENDLOOP. + ENDMETHOD. + + + METHOD calculate_sortiment_inhe. + DATA: lv_iteration TYPE i, + lv_changes TYPE i, + lt_material_list TYPE STANDARD TABLE OF matnr. + + " Sammle alle zu verarbeitenden Materialien + LOOP AT gt_materials INTO DATA(ls_material). + APPEND ls_material-matnr TO lt_material_list. + ENDLOOP. + + " Iterative Vererbungsberechnung (max. 10 Iterationen) + DO 10 TIMES. + lv_iteration = sy-index. + CLEAR lv_changes. + + " Verarbeite alle Materialien + LOOP AT lt_material_list INTO DATA(lv_matnr). + + " Prüfe auf Änderung + READ TABLE gt_materials WITH KEY matnr = lv_matnr + ASSIGNING FIELD-SYMBOL(). + + IF sy-subrc <> 0. + CONTINUE. + ENDIF. + + " *** NEU: Status 99 = Ausgelaufen → Überspringen *** + IF -mstae = '99'. + " Nur beim ersten Durchlauf in Ergebnis aufnehmen + IF lv_iteration = 1. + READ TABLE gt_results WITH KEY matnr = lv_matnr + TRANSPORTING NO FIELDS. + IF sy-subrc <> 0. + APPEND VALUE #( + matnr = lv_matnr + old_code_sort = -zzlzcodsort + new_code_sort = '' + changed_sort = COND #( WHEN -zzlzcodsort IS NOT INITIAL + THEN abap_true ELSE abap_false ) + message = 'Status 99 - Ausgelaufen' + ) TO gt_results. + ENDIF. + ENDIF. + CONTINUE. " Nächstes Material + ENDIF. + + " Prüfe manuelle Sperre (Position 4 = '1') + IF strlen( -zzlzcodsort ) >= 4 AND + -zzlzcodsort+3(1) = '1'. + CONTINUE. + ENDIF. + + " Berechne neuen Sortimentscode + DATA(lv_new_code) = apply_sortiment_rules( lv_matnr ). + + " Prüfe ob sich der Code geändert hat + IF -zzlzcodsort <> lv_new_code. + " Speichere Änderung in Ergebnistabelle + READ TABLE gt_results ASSIGNING FIELD-SYMBOL() + WITH KEY matnr = lv_matnr. + + IF sy-subrc = 0. + -old_code_sort = -zzlzcodsort. + -new_code_sort = lv_new_code. + -changed_sort = abap_true. + ELSE. + APPEND VALUE #( + matnr = lv_matnr + old_code_sort = -zzlzcodsort + new_code_sort = lv_new_code + changed_sort = abap_true + ) TO gt_results. + ENDIF. + + " Update Material mit neuem Code + -zzlzcodsort = lv_new_code. + ADD 1 TO lv_changes. + ENDIF. + ENDLOOP. + + " Beende wenn keine Änderungen mehr + IF lv_changes = 0. + EXIT. + ENDIF. + ENDDO. + ENDMETHOD. + + METHOD apply_sortiment_rules. + DATA: lv_position_4 TYPE char1, + lv_has_s TYPE abap_bool, + lv_has_o TYPE abap_bool, + lv_has_e TYPE abap_bool, + lv_has_n TYPE abap_bool, + lv_has_a TYPE abap_bool, + lv_has_c TYPE abap_bool, + lv_parent_count TYPE i, + lv_sonder_weighted TYPE p DECIMALS 3, + lv_core_weighted TYPE p DECIMALS 3, + lv_total_weighted TYPE p DECIMALS 3, + lv_percentage TYPE p DECIMALS 2, + lv_pct_int TYPE i, + ls_current_mat TYPE ty_material, + ls_parent TYPE ty_material, + ls_relation TYPE ty_bom_relation, + ls_consumption TYPE ty_consumption. + + WRITE: / ''. + WRITE: / '╔══════════════════════════════════════════════════════════╗'. + WRITE: / '║ apply_sortiment_rules (NEU) für:', iv_matnr. + WRITE: / '╚══════════════════════════════════════════════════════════╝'. + + " ══════════════════════════════════════════════════════════════════ + " SCHRITT 1: HOLE AKTUELLES MATERIAL + " ══════════════════════════════════════════════════════════════════ + READ TABLE gt_materials WITH TABLE KEY matnr = iv_matnr INTO ls_current_mat. + IF sy-subrc <> 0. + WRITE: / ' ERROR: Material nicht in gt_materials!'. + rv_code = 'N0X0'. + RETURN. + ENDIF. + + " *** NEU: Status 99 = Ausgelaufen → Leerer Code *** + IF ls_current_mat-mstae = '99'. + WRITE: / ' Status 99 (Ausgelaufen) → Code wird geleert'. + rv_code = ''. + RETURN. + ENDIF. + + " Initialisiere Code falls leer + IF ls_current_mat-zzlzcodsort IS INITIAL. + ls_current_mat-zzlzcodsort = 'N0X0'. + MODIFY TABLE gt_materials FROM ls_current_mat. + ENDIF. + + WRITE: / ' Aktueller Code:', ls_current_mat-zzlzcodsort. + + " ══════════════════════════════════════════════════════════════════ + " SCHRITT 2: POSITION 4 (MANUELLE SPERRE) + " ══════════════════════════════════════════════════════════════════ + IF strlen( ls_current_mat-zzlzcodsort ) >= 4. + lv_position_4 = ls_current_mat-zzlzcodsort+3(1). + ELSE. + lv_position_4 = '0'. + ENDIF. + + IF lv_position_4 = '1'. + WRITE: / ' -> GESPERRT (Position 4 = 1), Code bleibt'. + rv_code = ls_current_mat-zzlzcodsort. + RETURN. + ENDIF. + + " ══════════════════════════════════════════════════════════════════ + " SCHRITT 3: SAMMLE PARENTS UND ANALYSIERE TYPEN + " ══════════════════════════════════════════════════════════════════ + WRITE: / ' Analysiere Parents:'. + CLEAR: lv_parent_count, lv_has_s, lv_has_o, lv_has_e, + lv_has_n, lv_has_a, lv_has_c. + + LOOP AT gt_bom_relations INTO ls_relation WHERE child = iv_matnr. + ADD 1 TO lv_parent_count. + + READ TABLE gt_materials WITH TABLE KEY matnr = ls_relation-parent INTO ls_parent. + IF sy-subrc <> 0. + WRITE: / ' Parent', ls_relation-parent, 'NICHT GEFUNDEN!'. + CONTINUE. + ENDIF. + + " *** NEU: Parent mit Status 99 überspringen *** + IF ls_parent-mstae = '99'. + WRITE: / ' Parent', ls_relation-parent, 'Status 99 → übersprungen'. + SUBTRACT 1 FROM lv_parent_count. + CONTINUE. + ENDIF. + + " Überspringe ZZZZ und leere Codes + IF ls_parent-zzlzcodsort = 'ZZZZ' OR ls_parent-zzlzcodsort IS INITIAL. + WRITE: / ' Parent', ls_relation-parent, 'übersprungen (ZZZZ/leer)'. + CONTINUE. + ENDIF. + + WRITE: / ' Parent:', ls_relation-parent, + 'Code:', ls_parent-zzlzcodsort, + 'Typ:', ls_parent-zzlzcodsort(1). + + " Setze Flags basierend auf 1. Stelle + CASE ls_parent-zzlzcodsort(1). + WHEN 'S'. lv_has_s = abap_true. + WHEN 'O'. lv_has_o = abap_true. + WHEN 'E'. lv_has_e = abap_true. + " WHEN 'N'. lv_has_n = abap_true. + WHEN 'A'. lv_has_a = abap_true. + WHEN 'C'. lv_has_c = abap_true. + ENDCASE. + ENDLOOP. + + WRITE: / ' Anzahl gültige Parents:', lv_parent_count. + WRITE: / ' Flags: S=', lv_has_s, 'O=', lv_has_o, 'E=', lv_has_e, + 'C=', lv_has_c, 'N=', lv_has_n, 'A=', lv_has_a. + + " ══════════════════════════════════════════════════════════════════ + " SCHRITT 4: KEINE PARENTS → CODE BLEIBT + " ══════════════════════════════════════════════════════════════════ + IF lv_parent_count = 0. + rv_code = ls_current_mat-zzlzcodsort. + WRITE: / ' -> Keine Parents, Code bleibt:', rv_code. + RETURN. + ENDIF. + + " ══════════════════════════════════════════════════════════════════ + " SCHRITT 5: REINE FÄLLE (NUR EIN TYP VORHANDEN) + " ══════════════════════════════════════════════════════════════════ + + " REGEL 1: Nur C → C0X* + IF lv_has_c = abap_true AND + lv_has_s = abap_false AND lv_has_o = abap_false AND + lv_has_e = abap_false AND lv_has_a = abap_false. + rv_code = 'C0X' && lv_position_4. + WRITE: / ' REGEL 1: Nur C → C0X*'. + RETURN. + ENDIF. + + " REGEL 2: Nur E → E0X* + IF lv_has_e = abap_true AND + lv_has_s = abap_false AND lv_has_o = abap_false AND + lv_has_c = abap_false AND lv_has_a = abap_false. + rv_code = 'E0X' && lv_position_4. + WRITE: / ' REGEL 2: Nur E → E0X*'. + RETURN. + ENDIF. + + " REGEL 3: Nur O → O0X* + IF lv_has_o = abap_true AND + lv_has_s = abap_false AND lv_has_e = abap_false AND + lv_has_c = abap_false AND lv_has_a = abap_false. + rv_code = 'O0X' && lv_position_4. + WRITE: / ' REGEL 3: Nur O → O0X*'. + RETURN. + ENDIF. + + " REGEL 4: Nur N → N0X* +* IF lv_has_n = abap_true AND +* lv_has_s = abap_false AND lv_has_o = abap_false AND +* lv_has_e = abap_false AND lv_has_c = abap_false AND lv_has_a = abap_false. +* rv_code = 'N0X' && lv_position_4. +* WRITE: / ' REGEL 4: Nur N → N0X*'. +* RETURN. +* ENDIF. + + " REGEL 5: Nur A → A0X* + IF lv_has_a = abap_true AND + lv_has_s = abap_false AND lv_has_o = abap_false AND + lv_has_e = abap_false AND lv_has_c = abap_false . + rv_code = 'A0X' && lv_position_4. + WRITE: / ' REGEL 5: Nur A → A0X*'. + RETURN. + ENDIF. + + " ══════════════════════════════════════════════════════════════════ + " SCHRITT 6: GEMISCHTE FÄLLE → PROZENTBERECHNUNG + " ══════════════════════════════════════════════════════════════════ + WRITE: / ' GEMISCHTER FALL → Prozentberechnung'. + WRITE: / ' Berechne Sonder-Anteil (O + E = 100%, C/N/A = 0%):'. + + CLEAR: lv_sonder_weighted, lv_core_weighted, lv_total_weighted. + + " Durchlaufe alle Parents und berechne gewichtete Anteile + LOOP AT gt_bom_relations INTO ls_relation WHERE child = iv_matnr. + READ TABLE gt_materials WITH TABLE KEY matnr = ls_relation-parent INTO ls_parent. + IF sy-subrc <> 0 OR ls_parent-zzlzcodsort IS INITIAL OR + ls_parent-zzlzcodsort = 'ZZZZ'. + CONTINUE. + ENDIF. + + " *** NEU: Parent mit Status 99 überspringen *** + IF ls_parent-mstae = '99'. + CONTINUE. + ENDIF. + + " Hole Verbrauch (mit Fallback) + READ TABLE gt_consumption WITH KEY matnr = ls_parent-matnr INTO ls_consumption. + DATA: lv_parent_verbrauch TYPE p DECIMALS 2. + + IF sy-subrc = 0 AND ls_consumption-gsv_korr > 0. + lv_parent_verbrauch = ls_consumption-gsv_korr. + ELSE. + lv_parent_verbrauch = 1. + ENDIF. + + " Gewichtete Menge = Menge × Verbrauch + DATA(lv_gewichtete_menge) = ls_relation-menge * lv_parent_verbrauch. + + WRITE: / ' Parent:', ls_parent-matnr, + ' Code:', ls_parent-zzlzcodsort, + ' Menge:', ls_relation-menge, + ' Verbrauch:', lv_parent_verbrauch, + ' Gewicht:', lv_gewichtete_menge. + + " Klassifiziere Parent-Code + CASE ls_parent-zzlzcodsort(1). + WHEN 'O' OR 'E'. + " O und E = 100% Sonder + lv_sonder_weighted = lv_sonder_weighted + lv_gewichtete_menge. + WRITE: ' → 100% Sonder'. + + WHEN 'S'. + " S-Code: Hole Prozentsatz aus Code + DATA(lv_s_prozent) = get_code_percentage( ls_parent-zzlzcodsort ). + lv_sonder_weighted = lv_sonder_weighted + + ( lv_gewichtete_menge * lv_s_prozent / 100 ). + lv_core_weighted = lv_core_weighted + + ( lv_gewichtete_menge * ( 100 - lv_s_prozent ) / 100 ). + WRITE: ' → ', lv_s_prozent, '% Sonder'. + + WHEN 'C' OR 'A'. + " C, N, A = 0% Sonder (100% Core) + lv_core_weighted = lv_core_weighted + lv_gewichtete_menge. + WRITE: ' → 0% Sonder (Core)'. + ENDCASE. + ENDLOOP. + + " Berechne Gesamtprozentsatz + lv_total_weighted = lv_sonder_weighted + lv_core_weighted. + + IF lv_total_weighted > 0. + lv_percentage = ( lv_sonder_weighted / lv_total_weighted ) * 100. + ELSE. + lv_percentage = 0. + ENDIF. + + WRITE: / ' Sonder-Gewicht:', lv_sonder_weighted. + WRITE: / ' Core-Gewicht:', lv_core_weighted. + WRITE: / ' Total-Gewicht:', lv_total_weighted. + WRITE: / ' Sonder-Prozentsatz:', lv_percentage, '%'. + + " ══════════════════════════════════════════════════════════════════ + " SCHRITT 7: SCHWELLENWERTE (FLOOR-LOGIK) + " ══════════════════════════════════════════════════════════════════ + + " Mini-Schwelle: 0 < % < 3 → Core + IF lv_percentage > 0 AND lv_percentage < 3. + rv_code = 'C0X' && lv_position_4. + WRITE: / ' Schwelle: <3% → C0X*'. + RETURN. + ENDIF. + + " Floor-Konvertierung (14.73% → 14) + lv_pct_int = lv_percentage. + + rv_code = COND #( + WHEN lv_pct_int >= 100 THEN 'S0X' + WHEN lv_pct_int >= 85 THEN 'S9T' + WHEN lv_pct_int >= 75 THEN 'S8T' + WHEN lv_pct_int >= 65 THEN 'S7T' + WHEN lv_pct_int >= 55 THEN 'S6T' + WHEN lv_pct_int >= 45 THEN 'S5T' + WHEN lv_pct_int >= 35 THEN 'S4T' + WHEN lv_pct_int >= 25 THEN 'S3T' + WHEN lv_pct_int >= 15 THEN 'S2T' + WHEN lv_pct_int >= 5 THEN 'S1T' + WHEN lv_pct_int >= 3 THEN 'S0T' + ELSE 'S0X' + ) && lv_position_4. + + WRITE: / ' Neuer Code (nach Schwellen):', rv_code. + WRITE: / '╚══════════════════════════════════════════════════════════╝'. + + ENDMETHOD. + + + + METHOD calculate_sonder_percentage. + DATA: lv_sonder_weighted TYPE p DECIMALS 3, + lv_core_weighted TYPE p DECIMALS 3, + lv_total_weighted TYPE p DECIMALS 3, + lv_parent_verbrauch TYPE p DECIMALS 3, + lv_gewichtete_menge TYPE p DECIMALS 3, + lv_sonder_prozent TYPE p DECIMALS 2, + ls_parent TYPE ty_material, + ls_relation TYPE ty_bom_relation, + ls_consumption TYPE ty_consumption. + + CLEAR: rv_percentage, lv_sonder_weighted, lv_core_weighted, lv_total_weighted. + + LOOP AT gt_bom_relations INTO ls_relation WHERE child = iv_matnr. + + READ TABLE gt_materials WITH TABLE KEY matnr = ls_relation-parent INTO ls_parent. + IF sy-subrc <> 0. + CONTINUE. + ENDIF. + + READ TABLE gt_consumption WITH KEY matnr = ls_parent-matnr INTO ls_consumption. + IF sy-subrc = 0 AND ls_consumption-gsv_korr > 0. + lv_parent_verbrauch = ls_consumption-gsv_korr. + ELSE. + lv_parent_verbrauch = 1. + ENDIF. + + + + lv_gewichtete_menge = ls_relation-menge * lv_parent_verbrauch. + + " S, O und E zählen als Sonder + CASE ls_parent-zzlzcodsort(1). + WHEN 'S' OR 'O' OR 'E'. + " *** NEU: Prüfe Position 3 für 'X' (100% Sonder) *** + IF strlen( ls_parent-zzlzcodsort ) >= 3 AND ls_parent-zzlzcodsort+2(1) = 'X'. + " 100% Sonder (O0X, E0X, S0X) + lv_sonder_weighted = lv_sonder_weighted + lv_gewichtete_menge. + ELSE. + " Prozent-Anteil berechnen + DATA(lv_parent_code) = ls_parent-zzlzcodsort. + DATA(lv_sonder_prozent_temp) = get_code_percentage( lv_parent_code ). + lv_sonder_prozent = lv_sonder_prozent_temp. + + lv_sonder_weighted = lv_sonder_weighted + + ( lv_gewichtete_menge * lv_sonder_prozent / 100 ). + lv_core_weighted = lv_core_weighted + + ( lv_gewichtete_menge * ( 100 - lv_sonder_prozent ) / 100 ). + ENDIF. + + WHEN 'C' OR 'N' OR 'A'. + " 100% Core + lv_core_weighted = lv_core_weighted + lv_gewichtete_menge. + ENDCASE. + ENDLOOP. + + lv_total_weighted = lv_sonder_weighted + lv_core_weighted. + + IF lv_total_weighted > 0. + rv_percentage = ( lv_sonder_weighted / lv_total_weighted ) * 100. + ELSE. + rv_percentage = 0. + ENDIF. + ENDMETHOD. + + + METHOD get_code_percentage. + " %-Wert aus S/O/E-Code ableiten (…nT / …0X) – Mittelwerte je Range + DATA: lv_digit TYPE c. + lv_digit = iv_code+1(1). + + rv_percentage = COND ty_percentage( + WHEN iv_code+2(1) = 'X' AND lv_digit = '0' THEN 100 " *0X = 100% + WHEN lv_digit = '9' THEN '92' " 85–99 → 92.0 + WHEN lv_digit = '8' THEN '79.5' " 75–84 → 79.5 + WHEN lv_digit = '7' THEN '69.5' " 65–74 → 69.5 + WHEN lv_digit = '6' THEN '59.5' + WHEN lv_digit = '5' THEN '49.5' + WHEN lv_digit = '4' THEN '39.5' + WHEN lv_digit = '3' THEN '29.5' + WHEN lv_digit = '2' THEN '19.5' + WHEN lv_digit = '1' THEN '9.5' + WHEN lv_digit = '0' AND iv_code+2(1) = 'T' THEN '2' " *0T = ~2% + ELSE '0' + ). + ENDMETHOD. + + + + METHOD build_graph_nodes. + DATA: lv_verbrauch_text TYPE string, + lv_child_count TYPE i, + lv_verbrauch_monat TYPE p DECIMALS 2, + lv_is_debug TYPE abap_bool. + + " Prüfe ob Debug-Material + lv_is_debug = me->is_debug_material( iv_matnr ). + + " Debug-Ausgabe für TOP-Material + IF iv_level = 0 AND lv_is_debug = abap_true. + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ BUILD_GRAPH_NODES für TOP-Material:', iv_matnr. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + "Write: / 'Anzahl Beziehungen in gt_bom_relations:', lines( gt_bom_relations ). + ENDIF. + + " Sicherheitsabbruch bei zu tiefer Rekursion + IF iv_level > 20. + IF lv_is_debug = abap_true. + "Write: / '*** ABBRUCH: Level > 20 erreicht ***'. + ENDIF. + RETURN. + ENDIF. + + " Hole Stammdaten des aktuellen Knotens + READ TABLE gt_materials WITH KEY matnr = iv_matnr INTO DATA(ls_material). + IF sy-subrc <> 0. + IF lv_is_debug = abap_true. + "Write: / '*** Material', iv_matnr, 'nicht in gt_materials gefunden ***'. + ENDIF. + RETURN. + ENDIF. + + " ===== VERBRAUCHSDATEN MIT FORMATIERUNG ===== + READ TABLE gt_consumption WITH KEY matnr = iv_matnr INTO DATA(ls_consumption). + + IF sy-subrc = 0 AND ls_consumption-gsv_korr > 0. + " Berechne Durchschnitt pro Monat + lv_verbrauch_monat = ls_consumption-gsv_korr / 12. + + " Formatiere mit Tausender-Trennzeichen und ohne Dezimalstellen + lv_verbrauch_text = |{ lv_verbrauch_monat NUMBER = USER DECIMALS = 0 } / Mon|. + + IF lv_is_debug = abap_true. + "Write: / 'Material', iv_matnr, ':'. + "Write: / ' Jahresverbrauch:', ls_consumption-gsv_korr. + "Write: / ' Monatsverbrauch:', lv_verbrauch_monat. + "Write: / ' Formatiert:', lv_verbrauch_text. + ENDIF. + ELSE. + " Kein Verbrauch vorhanden + lv_verbrauch_text = '0'. + + IF lv_is_debug = abap_true. + "Write: / 'Material', iv_matnr, ': KEIN Verbrauch in gt_consumption'. + ENDIF. + ENDIF. + + " ===== KNOTEN ZUR AUSGABE HINZUFÜGEN ===== + APPEND VALUE #( + node_key = iv_matnr + parent_key = iv_parent_key + matnr = ls_material-matnr + menge = iv_menge + zzlzcod = ls_material-zzlzcod + zzlzcodsort = ls_material-zzlzcodsort + level = iv_level + verbrauch_text = lv_verbrauch_text + ) TO ct_nodes. + + " ===== DEBUG: ZÄHLE KINDER ===== + IF lv_is_debug = abap_true. + CLEAR lv_child_count. + LOOP AT gt_bom_relations INTO DATA(ls_check) WHERE parent = iv_matnr. + ADD 1 TO lv_child_count. + ENDLOOP. + + IF lv_child_count > 0. + "Write: / ' Material', iv_matnr, 'hat', lv_child_count, 'Komponenten'. + ELSE. + "Write: / ' Material', iv_matnr, 'hat KEINE Komponenten (Endprodukt)'. + ENDIF. + ENDIF. + + " ===== REKURSION: VERARBEITE ALLE KINDER ===== + LOOP AT gt_bom_relations INTO DATA(ls_bom_rel) WHERE parent = iv_matnr. + + IF lv_is_debug = abap_true. + + DATA: lv_next_level TYPE i. + lv_next_level = iv_level + 1. + "Write: / ' → Rekursion für Kind:', ls_bom_rel-child, + "'Menge:', ls_bom_rel-menge, + "'Level:', lv_next_level. + + + + + ENDIF. + + " Rekursiver Aufruf für jedes Kind + me->build_graph_nodes( + EXPORTING + iv_matnr = ls_bom_rel-child + iv_parent_key = iv_matnr + iv_menge = ls_bom_rel-menge + iv_level = iv_level + 1 + CHANGING + ct_nodes = ct_nodes + ). + ENDLOOP. + + " ===== ABSCHLUSS FÜR TOP-MATERIAL ===== + IF iv_level = 0 AND lv_is_debug = abap_true. + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ BUILD_GRAPH_NODES ABGESCHLOSSEN'. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + "Write: / 'Anzahl Knoten im Baum:', lines( ct_nodes ). + ENDIF. + + ENDMETHOD. + + + + + " SORTIMENTSCODE ENDE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + METHOD display_gozinto_graph. + " Zeigt ALLE selektierten TOP-Materialien in EINEM ALV + " mit Code-Änderungen und Berechnungsformel + + TYPES: BEGIN OF ty_tree_extended, + top_material TYPE matnr, + hierarchy_text TYPE string, + matnr TYPE matnr, + menge TYPE kmpmg, + code_alt TYPE char4, + code_neu TYPE char4, + zzlzcodsort TYPE char4, + verbrauch TYPE string, + formel TYPE string, + verwendungen TYPE string, + level TYPE i, + END OF ty_tree_extended. + + DATA: lt_all_display TYPE STANDARD TABLE OF ty_tree_extended, + lt_nodes TYPE tt_nodes, + lv_mat_count TYPE i. + + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ KOMBINIERTE GRAPH-ANZEIGE (ALLE TOP-MATERIALIEN) ║'. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + + " ======================================== + " FÜR JEDES SELEKTIERTE MATERIAL + " ======================================== + LOOP AT s_matnr INTO DATA(ls_matnr_range). + IF ls_matnr_range-sign = 'I' AND ls_matnr_range-option = 'EQ'. + + ADD 1 TO lv_mat_count. + DATA(lv_top_mat) = ls_matnr_range-low. + + "Write: / 'Verarbeite TOP-Material', lv_mat_count, ':', lv_top_mat. + + " Baue Hierarchie + CLEAR lt_nodes. + me->build_graph_nodes( + EXPORTING + iv_matnr = lv_top_mat + iv_parent_key = '' + iv_menge = 1 + iv_level = 0 + CHANGING + ct_nodes = lt_nodes ). + + IF lt_nodes IS INITIAL. + "Write: / ' -> Keine Hierarchie gefunden'. + CONTINUE. + ENDIF. + + " ======================================== + " TRENNLINIE EINFÜGEN (außer beim ersten Material) + " ======================================== + IF lv_mat_count > 1. + APPEND VALUE #( + top_material = lv_top_mat + hierarchy_text = '────────────────────────────────────────────────' + level = -1 + ) TO lt_all_display. + ENDIF. + + " ======================================== + " VERARBEITE JEDEN KNOTEN + " ======================================== + LOOP AT lt_nodes INTO DATA(ls_node). + + " *** CODE VORHER/NACHHER *** + DATA: lv_code_alt TYPE char4, + lv_code_neu TYPE char4. + + READ TABLE gt_results INTO DATA(ls_result) + WITH KEY matnr = ls_node-matnr. + IF sy-subrc = 0. + lv_code_alt = ls_result-old_code. + lv_code_neu = ls_result-new_code. + ELSE. + lv_code_alt = ls_node-zzlzcod. + lv_code_neu = ls_node-zzlzcod. + ENDIF. + + " *** FORMEL BERECHNEN *** + DATA: lv_formel TYPE string. + CLEAR lv_formel. + + IF ls_node-matnr CA 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'. + " Nur für Komponenten (B-Materialien) + + DATA: lt_parent_info TYPE STANDARD TABLE OF string, + lv_total_verb TYPE p DECIMALS 2, + lv_a_verb TYPE p DECIMALS 2, + lv_parent_count TYPE i, + lv_has_a TYPE abap_bool, + lv_has_n TYPE abap_bool, + lv_has_e TYPE abap_bool. + + CLEAR: lt_parent_info, lv_total_verb, lv_a_verb, lv_parent_count, + lv_has_a, lv_has_n, lv_has_e. + + " Sammle Parent-Informationen + LOOP AT gt_bom_relations INTO DATA(ls_rel_form) + WHERE child = ls_node-matnr. + + ADD 1 TO lv_parent_count. + + READ TABLE gt_materials WITH KEY matnr = ls_rel_form-parent + INTO DATA(ls_parent_mat). + IF sy-subrc <> 0. + CONTINUE. + ENDIF. + + " Hole Verbrauch + READ TABLE gt_consumption WITH KEY matnr = ls_rel_form-parent + INTO DATA(ls_cons_form). + + DATA: lv_parent_verb TYPE p DECIMALS 2. + IF sy-subrc = 0 AND ls_cons_form-gsv_korr > 0. + lv_parent_verb = ls_cons_form-gsv_korr. + ELSE. + lv_parent_verb = 1. + ENDIF. + + DATA: lv_gewicht TYPE p DECIMALS 2. + lv_gewicht = ls_rel_form-menge * lv_parent_verb. + lv_total_verb = lv_total_verb + lv_gewicht. + + " Prüfe Code-Typ + CASE ls_parent_mat-zzlzcod+0(1). + WHEN 'A'. + lv_has_a = abap_true. + + DATA: lv_prozent TYPE p DECIMALS 2. + DATA(lv_digit) = ls_parent_mat-zzlzcod+1(1). + + IF lv_digit = '0' AND ls_parent_mat-zzlzcod+2(1) = 'X'. + lv_prozent = 100. + ELSEIF lv_digit BETWEEN '1' AND '9'. + CASE lv_digit. + WHEN '0'. lv_prozent = 2. + WHEN '1'. lv_prozent = 9. + WHEN '2'. lv_prozent = 19. + WHEN '3'. lv_prozent = 29. + WHEN '4'. lv_prozent = 39. + WHEN '5'. lv_prozent = 49. + WHEN '6'. lv_prozent = 59. + WHEN '7'. lv_prozent = 69. + WHEN '8'. lv_prozent = 79. + WHEN '9'. lv_prozent = 92. + ENDCASE. + ELSEIF lv_digit = '0' AND ls_parent_mat-zzlzcod+2(1) = 'T'. + lv_prozent = 2. + ENDIF. + + lv_a_verb = lv_a_verb + ( lv_gewicht * lv_prozent / 100 ). + + DATA: lv_parent_text TYPE string. + lv_parent_text = |{ ls_rel_form-parent }({ ls_parent_mat-zzlzcod }):{ lv_parent_verb NUMBER = USER DECIMALS = 0 }×{ lv_prozent }%|. + APPEND lv_parent_text TO lt_parent_info. + + WHEN 'E'. + lv_has_e = abap_true. + lv_parent_text = |{ ls_rel_form-parent }(E0X):{ lv_parent_verb NUMBER = USER DECIMALS = 0 }|. + APPEND lv_parent_text TO lt_parent_info. + + WHEN 'N'. + lv_has_n = abap_true. + lv_parent_text = |{ ls_rel_form-parent }(N0X):{ lv_parent_verb NUMBER = USER DECIMALS = 0 }×0%|. + APPEND lv_parent_text TO lt_parent_info. + + ENDCASE. + + ENDLOOP. + + " Erstelle Formel-Text + IF lv_parent_count > 0. + DATA: lv_regel TYPE string. + + IF lv_has_e = abap_true AND lv_has_n = abap_true. + lv_regel = 'E+N→N0X'. + lv_formel = |Regel:{ lv_regel } (N dominiert)|. + + ELSEIF lv_has_e = abap_true AND lv_has_n = abap_false. + lv_regel = 'E→E0X'. + lv_formel = |Regel:{ lv_regel } (E dominiert)|. + + ELSEIF lv_has_n = abap_true AND lv_has_a = abap_true AND lv_has_e = abap_false. + lv_regel = 'N+A→A0T'. + lv_formel = |Regel:{ lv_regel } (fix A0T)|. + + ELSEIF lv_has_a = abap_true AND lv_has_n = abap_false AND lv_has_e = abap_false. + DATA: lv_anteil TYPE p DECIMALS 2. + IF lv_total_verb > 0. + lv_anteil = ( lv_a_verb / lv_total_verb ) * 100. + ELSE. + lv_anteil = 0. + ENDIF. + + lv_formel = |A-Anteil={ lv_anteil NUMBER = USER DECIMALS = 1 }% (|. + + DATA: lv_count TYPE i. + LOOP AT lt_parent_info INTO DATA(lv_pinfo). + lv_count = lv_count + 1. + IF lv_count = 1. + lv_formel = |{ lv_formel }{ lv_pinfo }|. + ELSE. + lv_formel = |{ lv_formel }+{ lv_pinfo }|. + ENDIF. + IF lv_count >= 3. + IF lines( lt_parent_info ) > 3. + DATA(lv_rest) = lines( lt_parent_info ) - 3. + lv_formel = |{ lv_formel }+{ lv_rest }mehr|. + ENDIF. + EXIT. + ENDIF. + ENDLOOP. + + lv_formel = |{ lv_formel })→{ lv_code_neu }|. + + ELSEIF lv_has_n = abap_true AND lv_has_a = abap_false AND lv_has_e = abap_false. + lv_regel = 'Nur N'. + lv_formel = |Regel:{ lv_regel } → N0X|. + + ELSE. + lv_formel = 'Keine Vererbung'. + ENDIF. + + ELSE. + lv_formel = 'TOP-Material'. + ENDIF. + + ELSE. + lv_formel = 'TOP-Material (keine Vererbung)'. + ENDIF. + + " *** VERWENDUNGEN *** + DATA: lv_usage_text TYPE string. + CLEAR lv_usage_text. + + IF ls_node-matnr CA 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'. + DATA: lt_usage_info TYPE SORTED TABLE OF string + WITH UNIQUE KEY table_line, + lv_usage_count TYPE i, + lv_total_usages TYPE i. + + CLEAR lt_usage_info. + + LOOP AT gt_bom_relations INTO DATA(ls_rel_use) + WHERE child = ls_node-matnr. + + READ TABLE gt_consumption WITH KEY matnr = ls_rel_use-parent + INTO DATA(ls_cons_use). + + IF sy-subrc = 0 AND ls_cons_use-gsv_korr > 0 AND + ls_rel_use-parent NA 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'. + + DATA: lv_verbrauch_monat TYPE p DECIMALS 0. + lv_verbrauch_monat = ls_cons_use-gsv_korr / 12. + + DATA: lv_usage_entry TYPE string. + lv_usage_entry = |{ ls_rel_use-parent }({ lv_verbrauch_monat NUMBER = USER DECIMALS = 0 })|. + + INSERT lv_usage_entry INTO TABLE lt_usage_info. + lv_usage_count = lines( lt_usage_info ). + + IF lv_usage_count >= 10. + EXIT. + ENDIF. + ENDIF. + ENDLOOP. + + IF lt_usage_info IS NOT INITIAL. + lv_usage_text = 'Verwendet in: '. + DATA: lv_first TYPE abap_bool VALUE abap_true. + LOOP AT lt_usage_info INTO DATA(lv_usage_entry_text). + IF lv_first = abap_true. + lv_usage_text = |{ lv_usage_text }{ lv_usage_entry_text }|. + lv_first = abap_false. + ELSE. + lv_usage_text = |{ lv_usage_text }, { lv_usage_entry_text }|. + ENDIF. + ENDLOOP. + + CLEAR lv_total_usages. + LOOP AT gt_bom_relations TRANSPORTING NO FIELDS + WHERE child = ls_node-matnr. + ADD 1 TO lv_total_usages. + ENDLOOP. + + IF lv_total_usages > 10. + DATA: lv_remaining TYPE i. + lv_remaining = lv_total_usages - 10. + lv_usage_text = |{ lv_usage_text } (+{ lv_remaining } weitere)|. + ENDIF. + ELSE. + lv_usage_text = ''. + ENDIF. + ENDIF. + + " *** ERSTELLE ANZEIGE-EINTRAG *** + DATA(lv_indent) = repeat( val = ' ' occ = ls_node-level ). + DATA(lv_prefix) = COND string( + WHEN ls_node-level = 0 THEN '►' + WHEN ls_node-level = 1 THEN '├─' + WHEN ls_node-level = 2 THEN '│ └─' + ELSE '│ └─' ). + + APPEND VALUE #( + top_material = lv_top_mat + hierarchy_text = |{ lv_indent }{ lv_prefix } { ls_node-matnr }| + matnr = ls_node-matnr + menge = ls_node-menge + code_alt = lv_code_alt + code_neu = lv_code_neu + zzlzcodsort = ls_node-zzlzcodsort + verbrauch = ls_node-verbrauch_text + formel = lv_formel + verwendungen = lv_usage_text + level = ls_node-level + ) TO lt_all_display. + + ENDLOOP. + + ENDIF. + ENDLOOP. + + " ======================================== + " ANZEIGE ALS EIN ALV + " ======================================== + IF lt_all_display IS INITIAL. + MESSAGE 'Keine Daten für Graph-Anzeige' TYPE 'I'. + RETURN. + ENDIF. + + TRY. + cl_salv_table=>factory( + IMPORTING r_salv_table = DATA(lo_alv) + CHANGING t_table = lt_all_display ). + + DATA(lo_columns) = lo_alv->get_columns( ). + + lo_columns->get_column( 'TOP_MATERIAL' )->set_long_text( 'TOP-Material' ). + lo_columns->get_column( 'TOP_MATERIAL' )->set_output_length( 12 ). + + lo_columns->get_column( 'HIERARCHY_TEXT' )->set_long_text( 'Hierarchie' ). + lo_columns->get_column( 'HIERARCHY_TEXT' )->set_output_length( 40 ). + lo_columns->get_column( 'MATNR' )->set_visible( abap_false ). + lo_columns->get_column( 'LEVEL' )->set_visible( abap_false ). + lo_columns->get_column( 'MENGE' )->set_long_text( 'Menge' ). + + lo_columns->get_column( 'CODE_ALT' )->set_long_text( 'Code VORHER' ). + lo_columns->get_column( 'CODE_NEU' )->set_long_text( 'Code NACHHER' ). + lo_columns->get_column( 'CODE_ALT' )->set_output_length( 10 ). + lo_columns->get_column( 'CODE_NEU' )->set_output_length( 10 ). + + lo_columns->get_column( 'ZZLZCODSORT' )->set_long_text( 'Sort-Code' ). + lo_columns->get_column( 'VERBRAUCH' )->set_long_text( 'Verbrauch/Mon' ). + + lo_columns->get_column( 'FORMEL' )->set_long_text( 'Berechnungsformel' ). + lo_columns->get_column( 'FORMEL' )->set_output_length( 60 ). + + lo_columns->get_column( 'VERWENDUNGEN' )->set_long_text( 'Verwendungen' ). + lo_columns->get_column( 'VERWENDUNGEN' )->set_output_length( 80 ). + lo_columns->set_optimize( ). + + lo_alv->get_display_settings( )->set_list_header( + |Kombinierte Hierarchie: { lv_mat_count } TOP-Materialien| ). + + lo_alv->get_functions( )->set_all( ). + + lo_alv->display( ). + + CATCH cx_salv_msg. + MESSAGE 'Fehler bei kombinierter Graph-Anzeige' TYPE 'E'. + ENDTRY. + + "Write: / ''. + "Write: / '╔════════════════════════════════════════════════════════════╗'. + "Write: / '║ KOMBINIERTE ANZEIGE ABGESCHLOSSEN ║'. + "Write: / '╚════════════════════════════════════════════════════════════╝'. + "Write: / 'Anzahl TOP-Materialien:', lv_mat_count. + "Write: / 'Anzahl Zeilen im ALV:', lines( lt_all_display ). + + ENDMETHOD. + + + + METHOD display_results. + DATA: lo_alv TYPE REF TO cl_salv_table. + + TRY. + cl_salv_table=>factory( + IMPORTING + r_salv_table = lo_alv + CHANGING + t_table = gt_results ). + + " Spaltenoptimierung + lo_alv->get_columns( )->set_optimize( abap_true ). + + " Funktionen aktivieren + lo_alv->get_functions( )->set_all( abap_true ). + + " Anzeige + lo_alv->display( ). + + CATCH cx_salv_msg. + MESSAGE 'Fehler bei ALV-Anzeige' TYPE 'E'. + ENDTRY. + ENDMETHOD. + +ENDCLASS. + +*----------------------------------------------------------------------* +* Hauptprogramm +*----------------------------------------------------------------------* +START-OF-SELECTION. + + "Ersetzte Parameter 251217 + DATA(p_batch) = CONV i( 5000 ). + DATA(p_maxlv) = CONV i( 4 ). + + + + p_mm = abap_false. + p_di = abap_true. "Default wie vorher + p_gozin = abap_false. + "END + + + + + " Validierungen + " IF p_alles = abap_true AND s_matnr[] IS NOT INITIAL. + " MESSAGE 'Entweder "Alle Materialien" oder Selektion angeben' TYPE 'E'. + " ENDIF. + + IF p_batch > 50000. + MESSAGE 'Batch-Größe zu groß (max. 50000)' TYPE 'W'. + p_batch = 50000. + ENDIF. + + " Hauptverarbeitung + DATA(lo_processor) = NEW lcl_lifecycle_processor( ). + lo_processor->execute( ). + +END-OF-SELECTION. + " Ergebnisanzeige + + IF p_gozin = 'X'. + lo_processor->display_results( ). + " Graph-Anzeige für ein ausgewähltes Material + " NEU: Graph-Anzeige für ALLE selektierten Materialien (max. 4 Stufen) + DATA: lv_material_count TYPE i. + + LOOP AT s_matnr INTO DATA(ls_matnr_range). + " Verarbeite jeden Eintrag im Range + IF ls_matnr_range-sign = 'I' AND ls_matnr_range-option = 'EQ'. + " Einzelnes Material + ADD 1 TO lv_material_count. + ""Write: / ''. + ""Write: / '================================================'. + ""Write: / 'Gozinto-Graph für Material:', ls_matnr_range-low. + ""Write: / '================================================'. + " lo_processor->display_gozinto_graph( iv_top_material = ls_matnr_range-low ). + + ELSEIF ls_matnr_range-sign = 'I' AND ls_matnr_range-option = 'BT'. + " Range von Materialien + SELECT matnr FROM mara + INTO @DATA(lv_matnr) + WHERE matnr BETWEEN @ls_matnr_range-low AND @ls_matnr_range-high. + + ADD 1 TO lv_material_count. + ""Write: / ''. + ""Write: / '================================================'. + ""Write: / 'Gozinto-Graph für Material:', lv_matnr. + ""Write: / '================================================'. + lo_processor->display_gozinto_graph( iv_top_material = lv_matnr ). + + " Optional: Begrenzung auf z.B. max 10 Materialien + IF lv_material_count >= 10. + ""Write: / 'WARNUNG: Maximal 10 Graphen angezeigt. Weitere Materialien übersprungen.'. + EXIT. + ENDIF. + ENDSELECT. + ENDIF. + ENDLOOP. + + + lo_processor->display_gozinto_graph( iv_top_material = ls_matnr_range-low ). + + + IF lv_material_count = 0. + ""Write: / 'Keine Materialien für Gozinto-Graph gefunden.'. + ELSE. + ""Write: / ''. + ""Write: / '================================================'. + ""Write: / 'Gesamt', lv_material_count, 'Gozinto-Graphen angezeigt.'. + ENDIF. + + + + ENDIF. +*----------------------------------------------------------------------* +* Unit-Tests (wenn p_test = 'X') +*----------------------------------------------------------------------* +AT SELECTION-SCREEN. + IF p_test = abap_true AND sy-ucomm = 'ONLI'. + " Test-Framework aktivieren + PERFORM run_unit_tests. + ENDIF. + +*&---------------------------------------------------------------------* +*& Form run_unit_tests +*&---------------------------------------------------------------------* +FORM run_unit_tests. + DATA: lv_test_result TYPE string. + + ""Write: / '=== UNIT TESTS ==='. + + " Test 1: Dominanzmatrix + lv_test_result = 'Test Dominanzmatrix: '. + " Implementierung der Testlogik + ""Write: / lv_test_result, 'PASSED'. + + " Test 2: Zirkularitätsprüfung + lv_test_result = 'Test Zirkularität: '. + " Implementierung der Testlogik + ""Write: / lv_test_result, 'PASSED'. + "'KMAT', 'ZHAL', 'ZFER', 'HALB', 'FERT') + " AND zzlzcod NE 'ZZZZ'. + + " Test 3: Batch-Verarbeitung + lv_test_result = 'Test Batch-Verarbeitung: '. + " Implementierung der Testlogik + ""Write: / lv_test_result, 'PASSED'. + + ""Write: / '=================='. +ENDFORM. + + +*&---------------------------------------------------------------------* +*& Form PROTECT_VKNR_FROM_UPDATE +*& Schützt Materialien vor Update im LZCode-Modus +*& +*& Parameter iv_mode: +*& 'T' = Nur TOP-Materialien (ohne Parents in gt_bom_relations) +*& '1' = Alle mit Position 4 = '1' im Code +*& 'B' = Beides kombiniert +*&---------------------------------------------------------------------* +FORM protect_vknr_from_update USING iv_mode TYPE char1. + DATA: lv_removed_count TYPE i, + lv_code_char4 TYPE char4. + + " Nur im LZCode-Modus aktiv + IF p_lzc <> abap_true. + RETURN. + ENDIF. + + " Durchlaufe gt_results und schütze Materialien + LOOP AT gt_results ASSIGNING FIELD-SYMBOL(). + + DATA(lv_protect) = abap_false. + + " ══════════════════════════════════════════════════════════════ + " PRÜFUNG 1: TOP-Material? (keine Parents) + " ══════════════════════════════════════════════════════════════ + IF iv_mode = 'T' OR iv_mode = 'B'. + READ TABLE gt_bom_relations WITH KEY child = -matnr + TRANSPORTING NO FIELDS. + IF sy-subrc <> 0. + " Keine Parents → TOP-Material + lv_protect = abap_true. + ENDIF. + ENDIF. + + " ══════════════════════════════════════════════════════════════ + " PRÜFUNG 2: Position 4 = '1'? + " ══════════════════════════════════════════════════════════════ + IF iv_mode = '1' OR iv_mode = 'B'. + lv_code_char4 = -new_code. + IF strlen( lv_code_char4 ) >= 4. + IF lv_code_char4+3(1) = '1'. + lv_protect = abap_true. + ENDIF. + ENDIF. + ENDIF. + + " ══════════════════════════════════════════════════════════════ + " SCHÜTZEN: changed = false, Code bleibt + " ══════════════════════════════════════════════════════════════ + IF lv_protect = abap_true. + -changed = abap_false. + -new_code = -old_code. + ADD 1 TO lv_removed_count. + ENDIF. + + ENDLOOP. + + IF lv_removed_count > 0. + WRITE: / 'VKNR-Schutz (Modus:', iv_mode, '):', + lv_removed_count, 'Materialien geschützt'. + ENDIF. + +ENDFORM. + +*&---------------------------------------------------------------------* +*& Globale Tabelle für gesicherte VKNR-Codes +*&---------------------------------------------------------------------* +TYPES: BEGIN OF ty_vknr_backup, + matnr TYPE matnr, + old_code TYPE char4, + END OF ty_vknr_backup. + +DATA: gt_vknr_backup TYPE STANDARD TABLE OF ty_vknr_backup. + +*&---------------------------------------------------------------------* +*& Form SAVE_VKNR_CODES +*& Sichert Codes von Materialien mit Position 4 = '1' +*& UND Materialien ohne Buchstaben (nur numerisch = VKNR) +*&---------------------------------------------------------------------* +FORM save_vknr_codes. + DATA: lv_code_char4 TYPE char4, + lv_matnr_str TYPE string. + + CLEAR gt_vknr_backup. + + LOOP AT gt_materials INTO DATA(ls_mat). + + DATA(lv_save) = abap_false. + + " ══════════════════════════════════════════════════════════════ + " PRÜFUNG 1: Position 4 = '1' + " ══════════════════════════════════════════════════════════════ + lv_code_char4 = ls_mat-zzlzcod. + IF strlen( lv_code_char4 ) >= 4 AND lv_code_char4+3(1) = '1'. + lv_save = abap_true. + ENDIF. + + " ══════════════════════════════════════════════════════════════ + " PRÜFUNG 2: MATNR nur numerisch (keine Buchstaben = VKNR) + " ══════════════════════════════════════════════════════════════ + lv_matnr_str = ls_mat-matnr. + CONDENSE lv_matnr_str NO-GAPS. + + IF lv_matnr_str CO '0123456789'. + lv_save = abap_true. + ENDIF. + + " ══════════════════════════════════════════════════════════════ + " SICHERN + " ══════════════════════════════════════════════════════════════ + IF lv_save = abap_true. + APPEND VALUE #( matnr = ls_mat-matnr + old_code = ls_mat-zzlzcod ) TO gt_vknr_backup. + ENDIF. + + ENDLOOP. + + IF lines( gt_vknr_backup ) > 0. + WRITE: / 'VKNR-Codes gesichert:', lines( gt_vknr_backup ), 'Materialien'. + ENDIF. + +ENDFORM. + +*&---------------------------------------------------------------------* +*& Form RESTORE_VKNR_CODES +*& Rekonstruiert gesicherte VKNR-Codes in gt_results +*&---------------------------------------------------------------------* +FORM restore_vknr_codes. + DATA: lv_restored TYPE i. + + LOOP AT gt_vknr_backup INTO DATA(ls_backup). + + " Finde Material in gt_results + READ TABLE gt_results ASSIGNING FIELD-SYMBOL() + WITH KEY matnr = ls_backup-matnr. + + IF sy-subrc = 0. + " Rekonstruiere: new_code = old_code, changed = false + -new_code = ls_backup-old_code. + -changed = abap_false. + ADD 1 TO lv_restored. + ENDIF. + + ENDLOOP. + + IF lv_restored > 0. + WRITE: / 'VKNR-Codes rekonstruiert:', lv_restored, 'Materialien'. + ENDIF. + +ENDFORM. /// bitte das einbauen, weisst du wie ?Perfekt, jetzt habe ich alle Informationen. Hier ist der komplette, einbaufertige Code: + +Zusammenfassung der Änderungen + + + +Was +Wo +Beschreibung + + + + +Globale Variablen +Nach DATA: gt_vknr_backup... +Job-Steuerungsvariablen + + +FORM deactivate_job +Am Ende des Programms +Entfernt Startbedingung vor Lauf + + +FORM reactivate_job +Am Ende des Programms +Setzt Startbedingung nach Lauf + + +Aufruf in execute +In Methode execute +Aufrufe am Anfang und Ende + + + + +Code zum Einfügen +1. Globale Variablen (nach Zeile DATA: gt_vknr_backup TYPE STANDARD TABLE OF ty_vknr_backup.) +*&---------------------------------------------------------------------* +*& Globale Variablen für Job-Steuerung +*&---------------------------------------------------------------------* +DATA: gv_job_was_active TYPE abap_bool, + gv_job_count TYPE tbtcjob-jobcount. + +CONSTANTS: gc_job_name TYPE tbtcjob-jobname VALUE 'VC_AUFLOESUNG_ZLO', + gc_job_user TYPE tbtcjob-authcknam VALUE 'KOI'. + + +2. Änderung in Methode execute (Klasse lcl_lifecycle_processor) +Ersetze die komplette Methode execute durch: + METHOD execute. + DATA: lv_start_time TYPE timestampl, + lv_end_time TYPE timestampl. + + GET TIME STAMP FIELD lv_start_time. + + " *** NEU: Job deaktivieren (nur produktiv) *** + PERFORM deactivate_job. + + " Phase 1: Datenladen + load_materials( ). + + me->debug_show_relations( ). + + load_consumption_data( ). + + " Phase 5: Berechnungen basierend auf Auswahl durchführen + IF p_lzc = abap_true. + PERFORM save_vknr_codes. + calculate_inheritance( ). + PERFORM restore_vknr_codes. + + IF p_upda = abap_true AND p_test = abap_false. + update_database( ). + ENDIF. + + ELSEIF p_sort = abap_true. + calculate_sortiment_inhe( ). + + IF p_upda = abap_true AND p_test = abap_false. + update_database_sortiment( ). + ENDIF. + ENDIF. + + GET TIME STAMP FIELD lv_end_time. + DATA(lv_runtime) = lv_end_time - lv_start_time. + + " *** NEU: Job reaktivieren (nur produktiv) *** + PERFORM reactivate_job. + + ENDMETHOD. + + +3. Neue FORMs (am Ende des Programms, vor AT SELECTION-SCREEN) +*&---------------------------------------------------------------------* +*& Form DEACTIVATE_JOB +*& Entfernt Startbedingung des Jobs VC_AUFLOESUNG_ZLO +*& Nur im produktiven Modus (p_test = abap_false) +*&---------------------------------------------------------------------* +FORM deactivate_job. + DATA: lt_joblist TYPE STANDARD TABLE OF tbtcjob, + ls_job TYPE tbtcjob. + + " Nur im produktiven Modus + IF p_test = abap_true. + WRITE: / 'Testmodus: Job-Deaktivierung übersprungen'. + RETURN. + ENDIF. + + CLEAR: gv_job_was_active, gv_job_count. + + " Suche freigegebenen Job (Status 'S' = Scheduled/Released) + CALL FUNCTION 'BP_JOB_SELECT' + EXPORTING + jobselect_dialog = abap_false + jobname = gc_job_name + username = gc_job_user + TABLES + jobselect_joblist = lt_joblist + EXCEPTIONS + OTHERS = 1. + + IF sy-subrc <> 0. + WRITE: / 'WARNUNG: Job', gc_job_name, 'nicht gefunden'. + RETURN. + ENDIF. + + " Finde freigegebenen Job (Status S = Scheduled) + LOOP AT lt_joblist INTO ls_job WHERE status = 'S'. + EXIT. + ENDLOOP. + + IF sy-subrc <> 0. + WRITE: / 'INFO: Kein freigegebener Job', gc_job_name, 'gefunden'. + RETURN. + ENDIF. + + " Sichere Job-Count für spätere Reaktivierung + gv_job_count = ls_job-jobcount. + gv_job_was_active = abap_true. + + " Entferne Startbedingung (Job auf 'P' = Planned setzen) + CALL FUNCTION 'BP_JOB_MODIFY' + EXPORTING + jobname = gc_job_name + jobcount = gv_job_count + new_status = 'P' + EXCEPTIONS + OTHERS = 1. + + IF sy-subrc = 0. + WRITE: / 'Job', gc_job_name, 'deaktiviert (Startbedingung entfernt)'. + ELSE. + WRITE: / 'FEHLER: Job', gc_job_name, 'konnte nicht deaktiviert werden'. + CLEAR gv_job_was_active. + ENDIF. + +ENDFORM. + +*&---------------------------------------------------------------------* +*& Form REACTIVATE_JOB +*& Setzt Startbedingung des Jobs wieder (01:00 nächster Tag, täglich) +*& Nur wenn Job vorher aktiv war und produktiver Modus +*&---------------------------------------------------------------------* +FORM reactivate_job. + DATA: lv_next_date TYPE sy-datum, + lv_start_time TYPE sy-uzeit VALUE '010000'. + + " Nur im produktiven Modus und wenn Job vorher aktiv war + IF p_test = abap_true. + WRITE: / 'Testmodus: Job-Reaktivierung übersprungen'. + RETURN. + ENDIF. + + IF gv_job_was_active = abap_false. + WRITE: / 'INFO: Job war nicht aktiv, keine Reaktivierung nötig'. + RETURN. + ENDIF. + + " Berechne nächsten Tag + lv_next_date = sy-datum + 1. + + " Setze Startbedingung wieder (täglich um 01:00) + CALL FUNCTION 'BP_JOB_MODIFY' + EXPORTING + jobname = gc_job_name + jobcount = gv_job_count + new_status = 'S' + new_sdlstrtdt = lv_next_date + new_sdlstrttm = lv_start_time + new_prddays = 1 + new_periodic = abap_true + EXCEPTIONS + OTHERS = 1. + + IF sy-subrc = 0. + WRITE: / 'Job', gc_job_name, 'reaktiviert für', lv_next_date, lv_start_time. + ELSE. + WRITE: / 'FEHLER: Job', gc_job_name, 'konnte nicht reaktiviert werden!'. + WRITE: / 'ACHTUNG: Job muss manuell in SM37 freigegeben werden!'. + ENDIF. + +ENDFORM. + + +Verhalten + + + +Situation +Verhalten + + + + +Testmodus (p_test = 'X') +Job wird NICHT deaktiviert/reaktiviert + + +Produktiv (p_test = ' ') +Job wird vor Start deaktiviert, nach Ende reaktiviert + + +Job nicht gefunden +Warnung, Programm läuft weiter + + +Programmabbruch +Job bleibt deaktiviert (gewünscht) + + +Reaktivierung +Täglich 01:00 Uhr, periodisch + + + + +Einbau-Anleitung + +Globale Variablen nach DATA: gt_vknr_backup... einfügen +Methode execute ersetzen (2 neue PERFORM-Aufrufe) +Zwei FORMs am Ende einfügen (vor AT SELECTION-SCREEN) +Aktivieren und testen (erst mit p_test = 'X') diff --git a/video-converter-suite/.gitignore b/video-converter-suite/.gitignore new file mode 100644 index 0000000..aee17c0 --- /dev/null +++ b/video-converter-suite/.gitignore @@ -0,0 +1,15 @@ +/vendor/ +/storage/uploads/* +/storage/outputs/* +/storage/thumbnails/* +/storage/logs/* +/storage/temp/* +!storage/uploads/.gitkeep +!storage/outputs/.gitkeep +!storage/thumbnails/.gitkeep +!storage/logs/.gitkeep +!storage/temp/.gitkeep +.env +*.swp +*.swo +.DS_Store diff --git a/video-converter-suite/Dockerfile b/video-converter-suite/Dockerfile new file mode 100644 index 0000000..9e793ac --- /dev/null +++ b/video-converter-suite/Dockerfile @@ -0,0 +1,40 @@ +FROM php:8.2-cli + +# Install FFmpeg and dependencies +RUN apt-get update && apt-get install -y \ + ffmpeg \ + libzip-dev \ + unzip \ + git \ + && docker-php-ext-install pcntl posix sockets \ + && rm -rf /var/lib/apt/lists/* + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +# Copy composer files first for caching +COPY composer.json ./ +RUN composer install --no-dev --optimize-autoloader 2>/dev/null || true + +# Copy application +COPY . . + +# Install dependencies +RUN composer install --no-dev --optimize-autoloader + +# Create storage directories +RUN mkdir -p storage/uploads storage/outputs storage/thumbnails storage/logs storage/temp \ + && chmod -R 777 storage + +# Configure PHP +RUN echo "upload_max_filesize = 5G\n\ +post_max_size = 5G\n\ +memory_limit = 512M\n\ +max_execution_time = 3600\n\ +max_input_time = 3600" > /usr/local/etc/php/conf.d/video-converter.ini + +EXPOSE 8080 8081 + +CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/router.php"] diff --git a/video-converter-suite/bin/queue-worker.php b/video-converter-suite/bin/queue-worker.php new file mode 100644 index 0000000..3da4331 --- /dev/null +++ b/video-converter-suite/bin/queue-worker.php @@ -0,0 +1,68 @@ +#!/usr/bin/env php +getAllJobs(), fn($j) => $j['status'] === 'running'); + $running = count($activeJobs); + + if ($running < $config['limits']['max_concurrent_jobs']) { + $nextJob = $queue->dequeue(); + if ($nextJob) { + echo "[" . date('H:i:s') . "] Processing: {$nextJob['queue_id']}\n"; + + try { + $result = $converter->convert([ + 'input_file' => $nextJob['input_file'] ?? '', + 'output_format' => $nextJob['output_format'] ?? 'mp4', + 'preset' => $nextJob['preset'] ?? 'balanced', + 'resolution' => $nextJob['resolution'] ?? null, + ]); + + if (isset($result['error'])) { + $queue->fail($nextJob['queue_id'], $result['error']); + echo "[" . date('H:i:s') . "] Failed: {$result['error']}\n"; + } else { + $queue->complete($nextJob['queue_id'], $result); + echo "[" . date('H:i:s') . "] Started job: {$result['id']}\n"; + } + } catch (\Throwable $e) { + $queue->fail($nextJob['queue_id'], $e->getMessage()); + echo "[" . date('H:i:s') . "] Error: {$e->getMessage()}\n"; + } + } + } + + sleep(2); +} diff --git a/video-converter-suite/bin/start.sh b/video-converter-suite/bin/start.sh new file mode 100755 index 0000000..703616f --- /dev/null +++ b/video-converter-suite/bin/start.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# Video Converter Suite - Startup Script +# Starts all services: Web Server, WebSocket Server, Queue Worker + +echo "================================================" +echo " VIDEO CONVERTER SUITE - Starting Services" +echo "================================================" +echo "" + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$DIR" + +# Create storage directories +mkdir -p storage/{uploads,outputs,thumbnails,logs,temp} + +# Check FFmpeg +if command -v ffmpeg &> /dev/null; then + echo "[OK] FFmpeg: $(ffmpeg -version 2>&1 | head -1)" +else + echo "[!!] FFmpeg not found. Install with: apt install ffmpeg" + echo " The application will work but conversions will fail." +fi + +# Check PHP +if command -v php &> /dev/null; then + echo "[OK] PHP: $(php -v 2>&1 | head -1)" +else + echo "[!!] PHP not found." + exit 1 +fi + +# Install dependencies if needed +if [ ! -d "vendor" ]; then + echo "" + echo "Installing dependencies..." + if command -v composer &> /dev/null; then + composer install + else + echo "[!!] Composer not found. WebSocket server won't work." + echo " The web interface will still work without it." + fi +fi + +echo "" +echo "Starting services..." +echo "" + +# Start Web Server +echo "[1/3] Web Server on http://localhost:8080" +php -S 0.0.0.0:8080 -t public public/router.php \ + -d upload_max_filesize=5G \ + -d post_max_size=5G \ + -d memory_limit=512M \ + -d max_execution_time=3600 \ + > storage/logs/web.log 2>&1 & +WEB_PID=$! + +# Start WebSocket Server (optional, requires Ratchet) +if [ -f "vendor/autoload.php" ]; then + echo "[2/3] WebSocket Server on ws://localhost:8081" + php bin/websocket-server.php > storage/logs/websocket.log 2>&1 & + WS_PID=$! +else + echo "[2/3] WebSocket Server: SKIPPED (run composer install first)" + WS_PID="" +fi + +# Start Queue Worker +echo "[3/3] Queue Worker" +php bin/queue-worker.php > storage/logs/worker.log 2>&1 & +WORKER_PID=$! + +echo "" +echo "================================================" +echo " All services started!" +echo "" +echo " Web UI: http://localhost:8080" +echo " WebSocket: ws://localhost:8081" +echo "" +echo " PIDs: Web=$WEB_PID WS=$WS_PID Worker=$WORKER_PID" +echo " Logs: storage/logs/" +echo "" +echo " Press Ctrl+C to stop all services" +echo "================================================" + +# Trap exit to kill all processes +cleanup() { + echo "" + echo "Stopping all services..." + kill $WEB_PID 2>/dev/null + [ -n "$WS_PID" ] && kill $WS_PID 2>/dev/null + kill $WORKER_PID 2>/dev/null + echo "All services stopped." + exit 0 +} + +trap cleanup EXIT INT TERM + +# Wait for any process to exit +wait diff --git a/video-converter-suite/bin/websocket-server.php b/video-converter-suite/bin/websocket-server.php new file mode 100644 index 0000000..4338450 --- /dev/null +++ b/video-converter-suite/bin/websocket-server.php @@ -0,0 +1,41 @@ +#!/usr/bin/env php +loop->addPeriodicTimer(2, function () use ($statusServer) { + $statusServer->broadcastStatus(); +}); + +echo "WebSocket server running. Press Ctrl+C to stop.\n"; +$server->run(); diff --git a/video-converter-suite/composer.json b/video-converter-suite/composer.json new file mode 100644 index 0000000..e63738f --- /dev/null +++ b/video-converter-suite/composer.json @@ -0,0 +1,21 @@ +{ + "name": "videokonverter/suite", + "description": "Video Converter Suite - Live Stream Pipeline Control Panel", + "type": "project", + "require": { + "php": ">=8.1", + "cboden/ratchet": "^0.4", + "react/event-loop": "^1.4", + "react/child-process": "^0.6" + }, + "autoload": { + "psr-4": { + "VideoConverter\\": "src/" + } + }, + "scripts": { + "start": "php -S 0.0.0.0:8080 -t public public/router.php", + "websocket": "php bin/websocket-server.php", + "worker": "php bin/queue-worker.php" + } +} diff --git a/video-converter-suite/config/app.php b/video-converter-suite/config/app.php new file mode 100644 index 0000000..29019ed --- /dev/null +++ b/video-converter-suite/config/app.php @@ -0,0 +1,74 @@ + 'Video Converter Suite', + 'version' => '1.0.0', + 'debug' => true, + + 'ffmpeg' => [ + 'binary' => getenv('FFMPEG_PATH') ?: '/usr/bin/ffmpeg', + 'ffprobe' => getenv('FFPROBE_PATH') ?: '/usr/bin/ffprobe', + 'threads' => (int)(getenv('FFMPEG_THREADS') ?: 4), + 'timeout' => 3600, + 'nice' => 10, + ], + + 'storage' => [ + 'uploads' => __DIR__ . '/../storage/uploads', + 'outputs' => __DIR__ . '/../storage/outputs', + 'thumbnails' => __DIR__ . '/../storage/thumbnails', + 'logs' => __DIR__ . '/../storage/logs', + 'temp' => __DIR__ . '/../storage/temp', + ], + + 'limits' => [ + 'max_upload_size' => 5 * 1024 * 1024 * 1024, // 5 GB + 'max_concurrent_jobs' => 3, + 'max_pipeline_depth' => 10, + ], + + 'websocket' => [ + 'host' => '0.0.0.0', + 'port' => 8081, + ], + + 'formats' => [ + 'video' => [ + 'mp4' => ['codec' => 'libx264', 'ext' => 'mp4', 'mime' => 'video/mp4'], + 'webm' => ['codec' => 'libvpx-vp9', 'ext' => 'webm', 'mime' => 'video/webm'], + 'mkv' => ['codec' => 'libx264', 'ext' => 'mkv', 'mime' => 'video/x-matroska'], + 'avi' => ['codec' => 'mpeg4', 'ext' => 'avi', 'mime' => 'video/x-msvideo'], + 'mov' => ['codec' => 'libx264', 'ext' => 'mov', 'mime' => 'video/quicktime'], + 'flv' => ['codec' => 'flv1', 'ext' => 'flv', 'mime' => 'video/x-flv'], + 'wmv' => ['codec' => 'wmv2', 'ext' => 'wmv', 'mime' => 'video/x-ms-wmv'], + 'ts' => ['codec' => 'libx264', 'ext' => 'ts', 'mime' => 'video/mp2t'], + 'hls' => ['codec' => 'libx264', 'ext' => 'm3u8', 'mime' => 'application/x-mpegURL'], + 'dash' => ['codec' => 'libx264', 'ext' => 'mpd', 'mime' => 'application/dash+xml'], + ], + 'audio' => [ + 'aac' => ['codec' => 'aac', 'ext' => 'aac', 'mime' => 'audio/aac'], + 'mp3' => ['codec' => 'libmp3lame', 'ext' => 'mp3', 'mime' => 'audio/mpeg'], + 'ogg' => ['codec' => 'libvorbis', 'ext' => 'ogg', 'mime' => 'audio/ogg'], + 'wav' => ['codec' => 'pcm_s16le', 'ext' => 'wav', 'mime' => 'audio/wav'], + 'flac' => ['codec' => 'flac', 'ext' => 'flac', 'mime' => 'audio/flac'], + 'opus' => ['codec' => 'libopus', 'ext' => 'opus', 'mime' => 'audio/opus'], + ], + ], + + 'presets' => [ + 'ultrafast' => ['preset' => 'ultrafast', 'crf' => 28], + 'fast' => ['preset' => 'fast', 'crf' => 23], + 'balanced' => ['preset' => 'medium', 'crf' => 20], + 'quality' => ['preset' => 'slow', 'crf' => 18], + 'lossless' => ['preset' => 'veryslow', 'crf' => 0], + ], + + 'resolutions' => [ + '4k' => ['width' => 3840, 'height' => 2160, 'label' => '4K UHD'], + '1440p' => ['width' => 2560, 'height' => 1440, 'label' => '2K QHD'], + '1080p' => ['width' => 1920, 'height' => 1080, 'label' => 'Full HD'], + '720p' => ['width' => 1280, 'height' => 720, 'label' => 'HD'], + '480p' => ['width' => 854, 'height' => 480, 'label' => 'SD'], + '360p' => ['width' => 640, 'height' => 360, 'label' => 'Low'], + ], +]; diff --git a/video-converter-suite/docker-compose.yml b/video-converter-suite/docker-compose.yml new file mode 100644 index 0000000..230d2a3 --- /dev/null +++ b/video-converter-suite/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.8' + +services: + # Main Web Application + web: + build: . + ports: + - "8080:8080" + volumes: + - ./storage:/app/storage + - ./src:/app/src + - ./public:/app/public + - ./templates:/app/templates + - ./config:/app/config + environment: + - FFMPEG_PATH=/usr/bin/ffmpeg + - FFPROBE_PATH=/usr/bin/ffprobe + - FFMPEG_THREADS=4 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/system"] + interval: 30s + timeout: 10s + retries: 3 + + # WebSocket Server for real-time updates + websocket: + build: . + command: php bin/websocket-server.php + ports: + - "8081:8081" + volumes: + - ./storage:/app/storage + - ./src:/app/src + - ./config:/app/config + depends_on: + - web + restart: unless-stopped + + # Queue Worker for batch processing + worker: + build: . + command: php bin/queue-worker.php + volumes: + - ./storage:/app/storage + - ./src:/app/src + - ./config:/app/config + environment: + - FFMPEG_PATH=/usr/bin/ffmpeg + - FFPROBE_PATH=/usr/bin/ffprobe + - FFMPEG_THREADS=2 + depends_on: + - web + restart: unless-stopped diff --git a/video-converter-suite/public/api.php b/video-converter-suite/public/api.php new file mode 100644 index 0000000..c6cfb1c --- /dev/null +++ b/video-converter-suite/public/api.php @@ -0,0 +1,409 @@ + handleSystem(), + + // Convert + $resource === 'convert' && $method === 'POST' => handleConvert($input), + $resource === 'convert' && $action === 'batch' && $method === 'POST' => handleBatchConvert($input), + $resource === 'upload' && $method === 'POST' => handleUpload(), + + // Jobs + $resource === 'jobs' && $method === 'GET' && !$id => handleGetJobs(), + $resource === 'jobs' && $method === 'GET' && $id && $action === 'progress' => handleJobProgress($id), + $resource === 'jobs' && $method === 'GET' && $id => handleGetJob($id), + $resource === 'jobs' && $method === 'DELETE' && $id => handleDeleteJob($id), + $resource === 'jobs' && $action === 'cancel' && $method === 'POST' => handleCancelJob($id), + + // Streams + $resource === 'streams' && $method === 'GET' && !$id => handleGetStreams(), + $resource === 'streams' && $method === 'POST' => handleStartStream($input), + $resource === 'streams' && $method === 'GET' && $id => handleGetStream($id), + $resource === 'streams' && $method === 'DELETE' && $id => handleStopStream($id), + $resource === 'streams' && $action === 'switch' && $method === 'POST' => handleSwitchFormat($id, $input), + + // Pipelines + $resource === 'pipelines' && $method === 'GET' && !$id => handleGetPipelines(), + $resource === 'pipelines' && $method === 'POST' => handleCreatePipeline($input), + $resource === 'pipelines' && $method === 'GET' && $id => handleGetPipeline($id), + $resource === 'pipelines' && $method === 'PUT' && $id => handleUpdatePipeline($id, $input), + $resource === 'pipelines' && $method === 'DELETE' && $id => handleDeletePipeline($id), + $resource === 'pipelines' && $action === 'run' && $method === 'POST' => handleRunPipeline($id, $input), + $resource === 'pipelines' && $action === 'stage' && $method === 'POST' => handleAddStage($id, $input), + + // Queue + $resource === 'queue' && $method === 'GET' => handleGetQueue(), + $resource === 'queue' && $method === 'POST' => handleEnqueue($input), + $resource === 'queue' && $method === 'DELETE' => handleClearQueue(), + + // Formats info + $resource === 'formats' && $method === 'GET' => handleGetFormats(), + $resource === 'presets' && $method === 'GET' => handleGetPresets(), + $resource === 'resolutions' && $method === 'GET' => handleGetResolutions(), + + // Probe + $resource === 'probe' && $method === 'POST' => handleProbe($input), + + // Downloads + $resource === 'download' && $method === 'GET' && $id => handleDownload($id), + + default => ['error' => 'Not found', 'status' => 404], + }; + + $status = $response['status'] ?? 200; + unset($response['status']); + http_response_code($status); + echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + +} catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + +// ---- Handler Functions ---- + +function handleSystem(): array +{ + $load = sys_getloadavg(); + $config = require __DIR__ . '/../config/app.php'; + return [ + 'app' => $config['app_name'], + 'version' => $config['version'], + 'cpu_load' => $load, + 'memory' => [ + 'used' => memory_get_usage(true), + 'peak' => memory_get_peak_usage(true), + ], + 'disk' => [ + 'free' => disk_free_space('/'), + 'total' => disk_total_space('/'), + ], + 'php_version' => PHP_VERSION, + 'ffmpeg_available' => file_exists($config['ffmpeg']['binary']), + ]; +} + +function handleUpload(): array +{ + $config = require __DIR__ . '/../config/app.php'; + + if (empty($_FILES['file'])) { + return ['error' => 'No file uploaded', 'status' => 400]; + } + + $file = $_FILES['file']; + if ($file['error'] !== UPLOAD_ERR_OK) { + return ['error' => 'Upload error: ' . $file['error'], 'status' => 400]; + } + + if ($file['size'] > $config['limits']['max_upload_size']) { + return ['error' => 'File too large', 'status' => 400]; + } + + $uploadDir = $config['storage']['uploads']; + if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); + + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $safeName = bin2hex(random_bytes(8)) . '.' . $ext; + $destination = $uploadDir . '/' . $safeName; + + if (!move_uploaded_file($file['tmp_name'], $destination)) { + return ['error' => 'Failed to save file', 'status' => 500]; + } + + $probe = new \VideoConverter\Process\MediaProbe(); + $info = $probe->analyze($destination); + + // Generate thumbnail + $thumbDir = $config['storage']['thumbnails']; + if (!is_dir($thumbDir)) mkdir($thumbDir, 0755, true); + $thumbPath = $thumbDir . '/' . pathinfo($safeName, PATHINFO_FILENAME) . '.jpg'; + $probe->getThumbnail($destination, $thumbPath); + + return [ + 'file' => $safeName, + 'path' => $destination, + 'original_name' => $file['name'], + 'size' => $file['size'], + 'info' => $info, + 'thumbnail' => file_exists($thumbPath) ? '/api/thumbnail/' . pathinfo($safeName, PATHINFO_FILENAME) : null, + ]; +} + +function handleConvert(array $input): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->convert($input); +} + +function handleBatchConvert(array $input): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->batchConvert($input['input_file'] ?? '', $input['formats'] ?? []); +} + +function handleGetJobs(): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return ['jobs' => $converter->getAllJobs()]; +} + +function handleGetJob(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + $job = $converter->getJob($id); + return $job ? $job : ['error' => 'Job not found', 'status' => 404]; +} + +function handleJobProgress(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->getProgress($id); +} + +function handleCancelJob(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return ['success' => $converter->cancelJob($id)]; +} + +function handleDeleteJob(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + return ['success' => $converter->deleteJob($id)]; +} + +function handleGetStreams(): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return ['streams' => $mgr->getAllStreams(), 'stats' => $mgr->getStats()]; +} + +function handleStartStream(array $input): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return $mgr->startStream($input); +} + +function handleGetStream(string $id): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + $stream = $mgr->getStream($id); + return $stream ?: ['error' => 'Stream not found', 'status' => 404]; +} + +function handleStopStream(string $id): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return ['success' => $mgr->stopStream($id)]; +} + +function handleSwitchFormat(string $id, array $input): array +{ + $mgr = new \VideoConverter\Stream\StreamManager(); + return $mgr->switchFormat($id, $input['format'] ?? 'mp4', $input['resolution'] ?? null); +} + +function handleGetPipelines(): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + return ['pipelines' => $mgr->toArray()]; +} + +function handleCreatePipeline(array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->create($input['name'] ?? 'Unnamed Pipeline'); + + foreach (($input['stages'] ?? []) as $stageData) { + $stage = new \VideoConverter\Pipeline\PipelineStage( + $stageData['type'] ?? 'transcode', + $stageData['params'] ?? [], + $stageData['label'] ?? '', + $stageData['enabled'] ?? true + ); + $pipeline->addStage($stage); + } + + $mgr->save(); + return $pipeline->toArray(); +} + +function handleGetPipeline(string $id): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + return $pipeline ? $pipeline->toArray() : ['error' => 'Pipeline not found', 'status' => 404]; +} + +function handleUpdatePipeline(string $id, array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404]; + + if (isset($input['stages'])) { + // Rebuild stages + $ref = new \ReflectionProperty($pipeline, 'stages'); + $ref->setAccessible(true); + $ref->setValue($pipeline, []); + + foreach ($input['stages'] as $stageData) { + $stage = \VideoConverter\Pipeline\PipelineStage::fromArray($stageData); + $pipeline->addStage($stage); + } + } + + $mgr->save(); + return $pipeline->toArray(); +} + +function handleDeletePipeline(string $id): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + return ['success' => $mgr->delete($id)]; +} + +function handleRunPipeline(string $id, array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404]; + + $converter = new \VideoConverter\Format\FormatConverter(); + return $converter->convert([ + 'input_file' => $input['input_file'] ?? '', + 'output_format' => $input['output_format'] ?? 'mp4', + 'pipeline' => $pipeline, + ]); +} + +function handleAddStage(string $id, array $input): array +{ + $mgr = new \VideoConverter\Pipeline\PipelineManager(); + $pipeline = $mgr->get($id); + if (!$pipeline) return ['error' => 'Pipeline not found', 'status' => 404]; + + $stage = new \VideoConverter\Pipeline\PipelineStage( + $input['type'] ?? 'transcode', + $input['params'] ?? [], + $input['label'] ?? '', + $input['enabled'] ?? true + ); + $pipeline->addStage($stage); + $mgr->save(); + + return $pipeline->toArray(); +} + +function handleGetQueue(): array +{ + $queue = new \VideoConverter\Queue\JobQueue(); + return ['queue' => $queue->getQueue(), 'stats' => $queue->getStats()]; +} + +function handleEnqueue(array $input): array +{ + $queue = new \VideoConverter\Queue\JobQueue(); + $queueId = $queue->enqueue($input); + return ['queue_id' => $queueId, 'position' => count($queue->getWaiting())]; +} + +function handleClearQueue(): array +{ + $queue = new \VideoConverter\Queue\JobQueue(); + $cleared = $queue->clear(); + return ['cleared' => $cleared]; +} + +function handleGetFormats(): array +{ + $config = require __DIR__ . '/../config/app.php'; + return $config['formats']; +} + +function handleGetPresets(): array +{ + $config = require __DIR__ . '/../config/app.php'; + return $config['presets']; +} + +function handleGetResolutions(): array +{ + $config = require __DIR__ . '/../config/app.php'; + return $config['resolutions']; +} + +function handleProbe(array $input): array +{ + $probe = new \VideoConverter\Process\MediaProbe(); + return $probe->analyze($input['file'] ?? ''); +} + +function handleDownload(string $id): array +{ + $converter = new \VideoConverter\Format\FormatConverter(); + $job = $converter->getJob($id); + if (!$job || !isset($job['output_file']) || !file_exists($job['output_file'])) { + return ['error' => 'File not found', 'status' => 404]; + } + + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . basename($job['output_file']) . '"'); + header('Content-Length: ' . filesize($job['output_file'])); + readfile($job['output_file']); + exit; +} diff --git a/video-converter-suite/public/css/controlpanel.css b/video-converter-suite/public/css/controlpanel.css new file mode 100644 index 0000000..8e9ccc4 --- /dev/null +++ b/video-converter-suite/public/css/controlpanel.css @@ -0,0 +1,1179 @@ +/* ============================================ + VIDEO CONVERTER SUITE - NUCLEAR CONTROL PANEL + Industrial / Power Plant Control Room Theme + ============================================ */ + +:root { + --bg-dark: #0a0e14; + --bg-panel: #111820; + --bg-module: #151c26; + --bg-inset: #0d1117; + --border-dark: #1e2a38; + --border-glow: #1a3a5c; + --text-primary: #c8d6e5; + --text-secondary: #6b7d8f; + --text-dim: #3d4f61; + --accent-blue: #0ea5e9; + --accent-cyan: #22d3ee; + --accent-green: #10b981; + --accent-yellow: #f59e0b; + --accent-orange: #f97316; + --accent-red: #ef4444; + --accent-purple: #8b5cf6; + --glow-blue: 0 0 10px rgba(14, 165, 233, 0.3); + --glow-green: 0 0 10px rgba(16, 185, 129, 0.3); + --glow-red: 0 0 10px rgba(239, 68, 68, 0.3); + --glow-yellow: 0 0 10px rgba(245, 158, 11, 0.3); + --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Consolas', monospace; + --font-display: 'Orbitron', 'Rajdhani', sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: var(--font-mono); + background: var(--bg-dark); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; +} + +/* Scanline overlay */ +body::after { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + pointer-events: none; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.03) 2px, + rgba(0, 0, 0, 0.03) 4px + ); + z-index: 9999; +} + +/* ---- TOP BAR ---- */ +.topbar { + background: linear-gradient(180deg, #0f1520 0%, #0a0e14 100%); + border-bottom: 1px solid var(--border-dark); + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.topbar-logo { + display: flex; + align-items: center; + gap: 12px; +} + +.topbar-logo .reactor-icon { + width: 32px; + height: 32px; + border: 2px solid var(--accent-cyan); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: var(--accent-cyan); + box-shadow: var(--glow-blue); + animation: pulse-glow 2s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 5px rgba(34, 211, 238, 0.3); } + 50% { box-shadow: 0 0 20px rgba(34, 211, 238, 0.6); } +} + +.topbar-title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-primary); +} + +.topbar-subtitle { + font-size: 10px; + color: var(--text-secondary); + letter-spacing: 1px; +} + +.topbar-status { + display: flex; + align-items: center; + gap: 24px; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); + box-shadow: var(--glow-green); + animation: blink 2s ease-in-out infinite; +} + +.status-dot.warning { background: var(--accent-yellow); box-shadow: var(--glow-yellow); } +.status-dot.error { background: var(--accent-red); box-shadow: var(--glow-red); animation: blink 0.5s ease-in-out infinite; } + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.clock { + font-family: var(--font-display); + font-size: 14px; + color: var(--accent-cyan); + letter-spacing: 2px; +} + +/* ---- NAVIGATION ---- */ +.nav-bar { + background: var(--bg-panel); + border-bottom: 1px solid var(--border-dark); + display: flex; + padding: 0 24px; + gap: 0; +} + +.nav-tab { + padding: 12px 20px; + font-size: 11px; + font-family: var(--font-mono); + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-secondary); + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + transition: all 0.2s; + position: relative; +} + +.nav-tab:hover { + color: var(--text-primary); + background: rgba(14, 165, 233, 0.05); +} + +.nav-tab.active { + color: var(--accent-cyan); + border-bottom-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.08); +} + +.nav-tab .badge { + position: absolute; + top: 6px; + right: 6px; + background: var(--accent-red); + color: white; + font-size: 9px; + padding: 1px 5px; + border-radius: 8px; + min-width: 16px; + text-align: center; +} + +/* ---- MAIN LAYOUT ---- */ +.main-container { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + padding: 16px 24px; + max-width: 1800px; + margin: 0 auto; +} + +.panel-row { + display: grid; + gap: 16px; +} + +.panel-row.cols-2 { grid-template-columns: 1fr 1fr; } +.panel-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; } +.panel-row.cols-4 { grid-template-columns: 1fr 1fr 1fr 1fr; } +.panel-row.cols-2-1 { grid-template-columns: 2fr 1fr; } +.panel-row.cols-1-2 { grid-template-columns: 1fr 2fr; } +.panel-row.cols-3-1 { grid-template-columns: 3fr 1fr; } + +/* ---- MODULE / PANEL ---- */ +.module { + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 4px; + overflow: hidden; +} + +.module-header { + background: linear-gradient(180deg, rgba(30, 42, 56, 0.5) 0%, transparent 100%); + padding: 10px 16px; + border-bottom: 1px solid var(--border-dark); + display: flex; + align-items: center; + justify-content: space-between; +} + +.module-title { + font-family: var(--font-display); + font-size: 11px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; +} + +.module-title .icon { + color: var(--accent-cyan); + font-size: 14px; +} + +.module-body { + padding: 16px; +} + +.module-footer { + padding: 8px 16px; + border-top: 1px solid var(--border-dark); + font-size: 10px; + color: var(--text-dim); + display: flex; + justify-content: space-between; +} + +/* ---- SYSTEM GAUGES ---- */ +.gauge-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.gauge { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px; + text-align: center; +} + +.gauge-label { + font-size: 9px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 8px; +} + +.gauge-value { + font-family: var(--font-display); + font-size: 24px; + font-weight: 700; + color: var(--accent-cyan); + line-height: 1; +} + +.gauge-unit { + font-size: 10px; + color: var(--text-secondary); + margin-top: 4px; +} + +.gauge-bar { + height: 4px; + background: var(--bg-dark); + border-radius: 2px; + margin-top: 8px; + overflow: hidden; +} + +.gauge-bar-fill { + height: 100%; + border-radius: 2px; + background: var(--accent-cyan); + transition: width 0.5s ease; + box-shadow: var(--glow-blue); +} + +.gauge-bar-fill.warning { background: var(--accent-yellow); box-shadow: var(--glow-yellow); } +.gauge-bar-fill.danger { background: var(--accent-red); box-shadow: var(--glow-red); } + +/* ---- INDUSTRIAL SWITCH / TOGGLE ---- */ +.switch-panel { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; +} + +.switch-unit { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 10px 8px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.switch-unit:hover { + border-color: var(--border-glow); + background: rgba(14, 165, 233, 0.05); +} + +.switch-unit.active { + border-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.1); + box-shadow: var(--glow-blue); +} + +.switch-unit.active .switch-led { + background: var(--accent-green); + box-shadow: var(--glow-green); +} + +.switch-led { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-dim); + margin: 0 auto 6px; + transition: all 0.2s; +} + +.switch-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-secondary); +} + +.switch-unit.active .switch-label { + color: var(--accent-cyan); +} + +/* ---- INDUSTRIAL TOGGLE SWITCH ---- */ +.toggle-switch { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; +} + +.toggle-track { + width: 40px; + height: 20px; + background: var(--bg-dark); + border: 2px solid var(--border-dark); + border-radius: 10px; + position: relative; + transition: all 0.3s; +} + +.toggle-track::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + background: var(--text-dim); + border-radius: 50%; + top: 2px; + left: 2px; + transition: all 0.3s; +} + +.toggle-switch.active .toggle-track { + background: rgba(16, 185, 129, 0.2); + border-color: var(--accent-green); +} + +.toggle-switch.active .toggle-track::after { + left: 22px; + background: var(--accent-green); + box-shadow: var(--glow-green); +} + +/* ---- ROTARY SELECTOR ---- */ +.rotary-selector { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.rotary-dial { + width: 80px; + height: 80px; + border-radius: 50%; + background: conic-gradient(from -90deg, var(--accent-cyan), var(--accent-blue), var(--accent-purple), var(--accent-cyan)); + padding: 3px; + position: relative; +} + +.rotary-inner { + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--bg-inset); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-display); + font-size: 14px; + font-weight: 700; + color: var(--accent-cyan); +} + +.rotary-label { + font-size: 9px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-dim); +} + +/* ---- PIPELINE VISUALIZATION ---- */ +.pipeline-canvas { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 20px; + min-height: 120px; + position: relative; + overflow-x: auto; +} + +.pipeline-flow { + display: flex; + align-items: center; + gap: 0; + min-width: max-content; +} + +.pipeline-node { + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 10px 14px; + min-width: 100px; + text-align: center; + position: relative; + transition: all 0.2s; + cursor: pointer; +} + +.pipeline-node:hover { + border-color: var(--accent-cyan); + transform: translateY(-2px); +} + +.pipeline-node.active { + border-color: var(--accent-green); + box-shadow: var(--glow-green); +} + +.pipeline-node.disabled { + opacity: 0.4; + border-style: dashed; +} + +.pipeline-node .node-type { + font-size: 9px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; +} + +.pipeline-node .node-name { + font-size: 11px; + color: var(--text-primary); + font-weight: 600; +} + +.pipeline-node .node-status { + width: 6px; + height: 6px; + border-radius: 50%; + position: absolute; + top: 6px; + right: 6px; + background: var(--accent-green); +} + +.pipeline-node.disabled .node-status { background: var(--text-dim); } + +.pipeline-connector { + width: 40px; + height: 2px; + background: var(--border-dark); + position: relative; + flex-shrink: 0; +} + +.pipeline-connector::after { + content: ''; + position: absolute; + right: -4px; + top: -3px; + border: 4px solid transparent; + border-left-color: var(--border-dark); +} + +.pipeline-connector.active { + background: var(--accent-cyan); + box-shadow: var(--glow-blue); +} + +.pipeline-connector.active::after { + border-left-color: var(--accent-cyan); +} + +/* ---- PROGRESS BAR ---- */ +.progress-bar { + height: 6px; + background: var(--bg-dark); + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan)); + border-radius: 3px; + transition: width 0.3s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; right: 0; + width: 30px; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2)); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { opacity: 0; } + 50% { opacity: 1; } + 100% { opacity: 0; } +} + +.progress-label { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--text-secondary); + margin-top: 4px; +} + +/* ---- JOB LIST ---- */ +.job-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.job-item { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px; + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: 12px; + align-items: center; + transition: all 0.2s; +} + +.job-item:hover { + border-color: var(--border-glow); +} + +.job-thumb { + width: 48px; + height: 36px; + background: var(--bg-dark); + border-radius: 2px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--text-dim); +} + +.job-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.job-info .job-name { + font-size: 12px; + color: var(--text-primary); + margin-bottom: 4px; +} + +.job-info .job-meta { + font-size: 10px; + color: var(--text-dim); +} + +.job-status { + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 3px; + font-weight: 600; +} + +.job-status.running { + color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.1); + border: 1px solid rgba(14, 165, 233, 0.3); +} + +.job-status.completed { + color: var(--accent-green); + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.job-status.error { + color: var(--accent-red); + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.job-status.queued { + color: var(--accent-yellow); + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.job-actions { + display: flex; + gap: 6px; +} + +/* ---- BUTTONS ---- */ +.btn { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 1px; + text-transform: uppercase; + padding: 6px 14px; + border: 1px solid var(--border-dark); + border-radius: 3px; + background: var(--bg-inset); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.btn:hover { + border-color: var(--accent-cyan); + color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.08); +} + +.btn-primary { + background: rgba(14, 165, 233, 0.15); + border-color: var(--accent-cyan); + color: var(--accent-cyan); +} + +.btn-primary:hover { + background: rgba(14, 165, 233, 0.25); + box-shadow: var(--glow-blue); +} + +.btn-danger { + border-color: rgba(239, 68, 68, 0.4); + color: var(--accent-red); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.1); + box-shadow: var(--glow-red); +} + +.btn-success { + border-color: rgba(16, 185, 129, 0.4); + color: var(--accent-green); +} + +.btn-success:hover { + background: rgba(16, 185, 129, 0.1); + box-shadow: var(--glow-green); +} + +.btn-icon { + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.btn-large { + padding: 10px 24px; + font-size: 12px; +} + +/* Emergency button */ +.btn-emergency { + background: var(--accent-red); + color: white; + border: 2px solid #dc2626; + border-radius: 6px; + font-weight: 700; + padding: 8px 20px; + box-shadow: 0 0 15px rgba(239, 68, 68, 0.4); + text-shadow: 0 1px 2px rgba(0,0,0,0.3); +} + +.btn-emergency:hover { + background: #dc2626; + box-shadow: 0 0 25px rgba(239, 68, 68, 0.6); + color: white; +} + +/* ---- FORM ELEMENTS ---- */ +.form-group { + margin-bottom: 12px; +} + +.form-label { + font-size: 10px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; + display: block; +} + +.form-input { + width: 100%; + padding: 8px 12px; + font-family: var(--font-mono); + font-size: 12px; + background: var(--bg-dark); + border: 1px solid var(--border-dark); + border-radius: 3px; + color: var(--text-primary); + transition: all 0.2s; +} + +.form-input:focus { + outline: none; + border-color: var(--accent-cyan); + box-shadow: var(--glow-blue); +} + +.form-select { + width: 100%; + padding: 8px 12px; + font-family: var(--font-mono); + font-size: 12px; + background: var(--bg-dark); + border: 1px solid var(--border-dark); + border-radius: 3px; + color: var(--text-primary); + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7d8f'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; +} + +.form-select:focus { + outline: none; + border-color: var(--accent-cyan); +} + +/* ---- UPLOAD ZONE ---- */ +.upload-zone { + border: 2px dashed var(--border-dark); + border-radius: 8px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + background: var(--bg-inset); +} + +.upload-zone:hover, +.upload-zone.dragover { + border-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.05); +} + +.upload-zone .upload-icon { + font-size: 48px; + color: var(--text-dim); + margin-bottom: 12px; +} + +.upload-zone .upload-text { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.upload-zone .upload-hint { + font-size: 10px; + color: var(--text-dim); +} + +/* ---- LOG CONSOLE ---- */ +.log-console { + background: #000; + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px; + font-size: 11px; + line-height: 1.6; + max-height: 200px; + overflow-y: auto; + font-family: var(--font-mono); +} + +.log-line { color: var(--text-dim); } +.log-line.info { color: var(--accent-cyan); } +.log-line.warn { color: var(--accent-yellow); } +.log-line.error { color: var(--accent-red); } +.log-line.success { color: var(--accent-green); } + +.log-time { + color: var(--text-dim); + margin-right: 8px; +} + +/* ---- STREAM MATRIX ---- */ +.stream-matrix { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.stream-card { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 4px; + overflow: hidden; +} + +.stream-preview { + aspect-ratio: 16/9; + background: #000; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.stream-preview .no-signal { + font-family: var(--font-display); + font-size: 14px; + color: var(--text-dim); + letter-spacing: 3px; +} + +.stream-preview .live-badge { + position: absolute; + top: 8px; + right: 8px; + background: var(--accent-red); + color: white; + font-size: 9px; + padding: 2px 6px; + border-radius: 2px; + font-weight: 700; + letter-spacing: 1px; + animation: blink 1s ease-in-out infinite; +} + +.stream-info { + padding: 10px; +} + +.stream-info .stream-name { + font-size: 12px; + margin-bottom: 6px; +} + +.stream-controls { + display: flex; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid var(--border-dark); +} + +/* ---- FORMAT MATRIX (NUCLEAR SWITCHBOARD) ---- */ +.format-matrix { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; +} + +.format-switch { + background: var(--bg-inset); + border: 1px solid var(--border-dark); + border-radius: 3px; + padding: 8px 6px; + text-align: center; + cursor: pointer; + transition: all 0.2s; + position: relative; + overflow: hidden; +} + +.format-switch::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: transparent; + transition: background 0.2s; +} + +.format-switch:hover { + border-color: var(--border-glow); +} + +.format-switch.selected { + border-color: var(--accent-cyan); + background: rgba(14, 165, 233, 0.08); +} + +.format-switch.selected::before { + background: var(--accent-cyan); + box-shadow: 0 0 8px rgba(14, 165, 233, 0.5); +} + +.format-switch .format-name { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 1px; +} + +.format-switch.selected .format-name { + color: var(--accent-cyan); +} + +.format-switch .format-desc { + font-size: 8px; + color: var(--text-dim); + margin-top: 2px; +} + +/* ---- SLIDER ---- */ +.slider-control { + display: flex; + align-items: center; + gap: 12px; +} + +.slider-control input[type="range"] { + flex: 1; + -webkit-appearance: none; + height: 4px; + background: var(--bg-dark); + border-radius: 2px; + outline: none; +} + +.slider-control input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent-cyan); + border: 2px solid var(--bg-module); + cursor: pointer; + box-shadow: var(--glow-blue); +} + +.slider-value { + font-family: var(--font-display); + font-size: 14px; + color: var(--accent-cyan); + min-width: 50px; + text-align: right; +} + +/* ---- RESPONSIVE ---- */ +@media (max-width: 1200px) { + .panel-row.cols-3 { grid-template-columns: 1fr 1fr; } + .panel-row.cols-4 { grid-template-columns: 1fr 1fr; } + .gauge-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 768px) { + .panel-row.cols-2, + .panel-row.cols-3, + .panel-row.cols-4, + .panel-row.cols-2-1, + .panel-row.cols-1-2 { grid-template-columns: 1fr; } + + .format-matrix { grid-template-columns: repeat(3, 1fr); } + .gauge-grid { grid-template-columns: 1fr 1fr; } + .main-container { padding: 8px; } + + .topbar { padding: 0 12px; } + .topbar-subtitle { display: none; } +} + +/* ---- MODAL ---- */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s; +} + +.modal-overlay.visible { + opacity: 1; + visibility: visible; +} + +.modal { + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 6px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + transform: translateY(20px); + transition: transform 0.3s; +} + +.modal-overlay.visible .modal { + transform: translateY(0); +} + +.modal-header { + padding: 16px; + border-bottom: 1px solid var(--border-dark); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-primary); +} + +.modal-close { + background: none; + border: none; + color: var(--text-dim); + font-size: 18px; + cursor: pointer; + padding: 4px; +} + +.modal-close:hover { color: var(--accent-red); } + +.modal-body { padding: 16px; } +.modal-footer { + padding: 12px 16px; + border-top: 1px solid var(--border-dark); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* ---- TOOLTIP ---- */ +[data-tooltip] { + position: relative; +} + +[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-dark); + border: 1px solid var(--border-dark); + padding: 4px 8px; + font-size: 10px; + color: var(--text-secondary); + white-space: nowrap; + border-radius: 3px; + z-index: 100; +} + +/* ---- NOTIFICATION ---- */ +.notification { + position: fixed; + top: 70px; + right: 20px; + background: var(--bg-module); + border: 1px solid var(--border-dark); + border-radius: 4px; + padding: 12px 16px; + font-size: 12px; + z-index: 1000; + display: flex; + align-items: center; + gap: 10px; + transform: translateX(120%); + transition: transform 0.3s ease; + max-width: 350px; +} + +.notification.show { transform: translateX(0); } +.notification.success { border-left: 3px solid var(--accent-green); } +.notification.error { border-left: 3px solid var(--accent-red); } +.notification.warning { border-left: 3px solid var(--accent-yellow); } +.notification.info { border-left: 3px solid var(--accent-cyan); } diff --git a/video-converter-suite/public/js/controlpanel.js b/video-converter-suite/public/js/controlpanel.js new file mode 100644 index 0000000..61ad2c6 --- /dev/null +++ b/video-converter-suite/public/js/controlpanel.js @@ -0,0 +1,828 @@ +/** + * Video Converter Suite - Control Panel JavaScript + * Nuclear Power Plant Style UI Controller + */ + +// ============ STATE ============ +const state = { + currentPage: 'dashboard', + selectedFormat: 'mp4', + selectedPreset: 'balanced', + selectedResolution: 'original', + uploadedFile: null, + uploadedFilePath: null, + jobs: [], + streams: [], + pipelines: [], + activePipelineId: null, + pipelineStages: [], + activeStreamId: null, + wsConnected: false, + refreshInterval: null, +}; + +// ============ INIT ============ +document.addEventListener('DOMContentLoaded', () => { + initClock(); + initNavigation(); + initUploadZone(); + startAutoRefresh(); + refreshStatus(); + addLog('System initialisiert', 'info'); +}); + +// ============ CLOCK ============ +function initClock() { + const el = document.getElementById('systemClock'); + function update() { + const now = new Date(); + el.textContent = now.toTimeString().split(' ')[0]; + } + update(); + setInterval(update, 1000); +} + +// ============ NAVIGATION ============ +function initNavigation() { + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', () => { + const page = tab.dataset.page; + switchPage(page); + }); + }); +} + +function switchPage(page) { + state.currentPage = page; + document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); + document.querySelector(`.nav-tab[data-page="${page}"]`)?.classList.add('active'); + document.querySelectorAll('.page-content').forEach(p => p.style.display = 'none'); + document.getElementById(`page-${page}`).style.display = ''; + + // Refresh page-specific data + if (page === 'dashboard') refreshStatus(); + if (page === 'streams') refreshStreams(); + if (page === 'pipelines') refreshPipelines(); + if (page === 'queue') refreshQueue(); +} + +// ============ UPLOAD ============ +function initUploadZone() { + const zone = document.getElementById('uploadZone'); + if (!zone) return; + + zone.addEventListener('dragover', (e) => { + e.preventDefault(); + zone.classList.add('dragover'); + }); + + zone.addEventListener('dragleave', () => { + zone.classList.remove('dragover'); + }); + + zone.addEventListener('drop', (e) => { + e.preventDefault(); + zone.classList.remove('dragover'); + if (e.dataTransfer.files.length > 0) { + uploadFile(e.dataTransfer.files[0]); + } + }); +} + +function handleFileSelect(event) { + if (event.target.files.length > 0) { + uploadFile(event.target.files[0]); + } +} + +async function uploadFile(file) { + state.uploadedFile = file; + addLog(`Upload gestartet: ${file.name} (${formatBytes(file.size)})`, 'info'); + + const formData = new FormData(); + formData.append('file', file); + + try { + const resp = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + const data = await resp.json(); + + if (data.error) { + addLog(`Upload-Fehler: ${data.error}`, 'error'); + notify('Upload fehlgeschlagen: ' + data.error, 'error'); + return; + } + + state.uploadedFilePath = data.path; + displayUploadedFile(data); + document.getElementById('btnStartConvert').disabled = false; + addLog(`Upload abgeschlossen: ${file.name}`, 'success'); + notify('Datei hochgeladen: ' + file.name, 'success'); + } catch (err) { + addLog(`Upload-Fehler: ${err.message}`, 'error'); + notify('Upload fehlgeschlagen', 'error'); + } +} + +function displayUploadedFile(data) { + const el = document.getElementById('uploadedFileInfo'); + el.style.display = 'block'; + + const info = data.info || {}; + const video = info.video || {}; + const audio = info.audio || {}; + + el.innerHTML = ` +
+
+ ${data.original_name} + ${formatBytes(data.size)} +
+
+
Format: ${info.format_name || 'N/A'}
+
Dauer: ${formatDuration(info.duration || 0)}
+ ${video ? ` +
Video: ${video.codec || 'N/A'} ${video.width || ''}x${video.height || ''}
+
FPS: ${video.fps || 'N/A'}
+ ` : ''} + ${audio ? ` +
Audio: ${audio.codec || 'N/A'}
+
Sample: ${audio.sample_rate || 'N/A'} Hz
+ ` : ''} +
+
+ `; +} + +// ============ FORMAT SELECTION ============ +function selectFormat(format) { + state.selectedFormat = format; + document.querySelectorAll('.format-switch').forEach(s => s.classList.remove('selected')); + document.querySelectorAll(`.format-switch[data-format="${format}"]`).forEach(s => s.classList.add('selected')); + addLog(`Format gewählt: ${format.toUpperCase()}`, 'info'); +} + +function selectPreset(preset) { + state.selectedPreset = preset; + document.querySelectorAll('#presetPanel .switch-unit').forEach(s => s.classList.remove('active')); + document.querySelector(`#presetPanel .switch-unit[data-preset="${preset}"]`)?.classList.add('active'); +} + +function selectResolution(res) { + state.selectedResolution = res; + document.querySelectorAll('#resolutionPanel .switch-unit').forEach(s => s.classList.remove('active')); + document.querySelector(`#resolutionPanel .switch-unit[data-resolution="${res}"]`)?.classList.add('active'); +} + +// ============ CONVERSION ============ +async function startConversion() { + if (!state.uploadedFilePath) { + notify('Keine Datei hochgeladen', 'warning'); + return; + } + + const params = { + input_file: state.uploadedFilePath, + output_format: state.selectedFormat, + preset: state.selectedPreset, + }; + + if (state.selectedResolution !== 'original') { + params.resolution = state.selectedResolution; + } + + addLog(`Konvertierung gestartet: ${state.selectedFormat.toUpperCase()} / ${state.selectedPreset}`, 'info'); + document.getElementById('conversionStatus').textContent = 'Konvertierung wird gestartet...'; + + try { + const resp = await fetch('/api/convert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = await resp.json(); + + if (data.error) { + addLog(`Fehler: ${data.error}`, 'error'); + notify(data.error, 'error'); + return; + } + + addLog(`Job erstellt: ${data.id}`, 'success'); + notify('Konvertierung gestartet', 'success'); + document.getElementById('btnStopAll').style.display = ''; + document.getElementById('conversionStatus').textContent = `Job ${data.id} läuft...`; + startJobPolling(data.id); + + } catch (err) { + addLog(`Fehler: ${err.message}`, 'error'); + notify('Konvertierung fehlgeschlagen', 'error'); + } +} + +function startJobPolling(jobId) { + const poll = setInterval(async () => { + try { + const resp = await fetch(`/api/jobs/${jobId}/progress`); + const progress = await resp.json(); + + document.getElementById('conversionStatus').textContent = + `${progress.percent || 0}% | FPS: ${progress.fps || 0} | Speed: ${progress.speed || '0x'} | Zeit: ${progress.time || '00:00:00'}`; + + updateJobInList(jobId, progress); + + if (progress.percent >= 100) { + clearInterval(poll); + addLog(`Job ${jobId} abgeschlossen`, 'success'); + notify('Konvertierung abgeschlossen!', 'success'); + document.getElementById('conversionStatus').textContent = 'Konvertierung abgeschlossen!'; + refreshJobs(); + } + } catch (e) { + // Keep polling + } + }, 1000); +} + +async function stopAllJobs() { + if (!confirm('Alle laufenden Jobs stoppen?')) return; + + try { + const resp = await fetch('/api/jobs'); + const data = await resp.json(); + for (const job of (data.jobs || [])) { + if (job.status === 'running') { + await fetch(`/api/jobs/${job.id}/cancel`, { method: 'POST' }); + addLog(`Job ${job.id} gestoppt`, 'warn'); + } + } + notify('Alle Jobs gestoppt', 'warning'); + document.getElementById('btnStopAll').style.display = 'none'; + refreshJobs(); + } catch (e) { + notify('Fehler beim Stoppen', 'error'); + } +} + +// ============ JOBS ============ +async function refreshJobs() { + try { + const resp = await fetch('/api/jobs'); + const data = await resp.json(); + state.jobs = data.jobs || []; + renderJobList(); + } catch (e) { + // Silently fail + } +} + +function renderJobList() { + const el = document.getElementById('jobList'); + if (!state.jobs.length) { + el.innerHTML = '
Keine aktiven Jobs
'; + return; + } + + el.innerHTML = state.jobs.map(job => ` +
+
${job.thumbnail ? `` : '🎦'}
+
+
${job.input_file ? job.input_file.split('/').pop() : 'Unknown'}
+
+ ${job.output_format?.toUpperCase() || ''} | ${job.preset || ''} | ${job.resolution || 'Original'} +
+
+
+
+
+ ${job.status === 'completed' ? '100%' : '0%'} + +
+
+ ${job.status} +
+ ${job.status === 'running' ? `` : ''} + ${job.status === 'completed' ? `` : ''} + +
+
+ `).join(''); + + // Update active job count + const running = state.jobs.filter(j => j.status === 'running').length; + document.getElementById('activeJobCount').textContent = running; + document.getElementById('gaugeJobs').textContent = running; +} + +function updateJobInList(jobId, progress) { + const bar = document.getElementById(`progress-${jobId}`); + const text = document.getElementById(`progress-text-${jobId}`); + const speed = document.getElementById(`progress-speed-${jobId}`); + if (bar) bar.style.width = `${progress.percent || 0}%`; + if (text) text.textContent = `${progress.percent || 0}%`; + if (speed) speed.textContent = `${progress.fps || 0} fps | ${progress.speed || ''}`; +} + +async function cancelJob(id) { + await fetch(`/api/jobs/${id}/cancel`, { method: 'POST' }); + addLog(`Job ${id} abgebrochen`, 'warn'); + refreshJobs(); +} + +async function deleteJob(id) { + await fetch(`/api/jobs/${id}`, { method: 'DELETE' }); + addLog(`Job ${id} gelöscht`, 'info'); + refreshJobs(); +} + +function downloadJob(id) { + window.open(`/api/download/${id}`, '_blank'); +} + +// ============ STREAMS ============ +async function refreshStreams() { + try { + const resp = await fetch('/api/streams'); + const data = await resp.json(); + state.streams = data.streams || []; + renderStreamMatrix(); + updateStreamSelect(); + } catch (e) {} +} + +function renderStreamMatrix() { + const el = document.getElementById('streamMatrix'); + if (!state.streams.length) { + el.innerHTML = '
Keine aktiven Streams
'; + return; + } + + el.innerHTML = state.streams.map(s => ` +
+
+ ${s.status === 'running' ? '▶ LIVE' : 'NO SIGNAL'} + ${s.status === 'running' ? 'LIVE' : ''} +
+
+
${s.input_url || 'Stream'}
+
+ ${s.output_format?.toUpperCase() || ''} | ${s.resolution || 'Original'} | ${s.preset || 'fast'} +
+ ${s.status} +
+
+ ${s.status === 'running' ? + `` : + `` + } + +
+
+ `).join(''); +} + +function updateStreamSelect() { + const sel = document.getElementById('activeStreamSelect'); + const runningStreams = state.streams.filter(s => s.status === 'running'); + sel.innerHTML = '' + + runningStreams.map(s => + `` + ).join(''); +} + +function openStreamModal() { + document.getElementById('streamModal').classList.add('visible'); +} + +function closeStreamModal() { + document.getElementById('streamModal').classList.remove('visible'); +} + +async function startNewStream() { + const inputUrl = document.getElementById('streamInputUrl').value; + if (!inputUrl) { + notify('Bitte Stream-URL eingeben', 'warning'); + return; + } + + const params = { + input_url: inputUrl, + output_format: document.getElementById('streamOutputFormat').value, + resolution: document.getElementById('streamResolution').value || null, + preset: document.getElementById('streamPreset').value, + }; + + try { + const resp = await fetch('/api/streams', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = await resp.json(); + + if (data.error) { + notify(data.error, 'error'); + return; + } + + addLog(`Stream gestartet: ${data.id}`, 'success'); + notify('Stream gestartet', 'success'); + closeStreamModal(); + refreshStreams(); + } catch (e) { + notify('Stream-Start fehlgeschlagen', 'error'); + } +} + +async function stopStream(id) { + await fetch(`/api/streams/${id}`, { method: 'DELETE' }); + addLog(`Stream ${id} gestoppt`, 'warn'); + refreshStreams(); +} + +async function deleteStream(id) { + await fetch(`/api/streams/${id}`, { method: 'DELETE' }); + refreshStreams(); +} + +function selectActiveStream(id) { + state.activeStreamId = id; + // Highlight current format + const stream = state.streams.find(s => s.id === id); + document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected')); + if (stream) { + document.querySelector(`[data-stream-format="${stream.output_format}"]`)?.classList.add('selected'); + } +} + +async function switchStreamFormat(format) { + if (!state.activeStreamId) { + notify('Bitte zuerst einen Stream wählen', 'warning'); + return; + } + + addLog(`Format-Wechsel: ${format.toUpperCase()} für Stream ${state.activeStreamId}`, 'warn'); + + try { + const resp = await fetch(`/api/streams/${state.activeStreamId}/switch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ format }), + }); + const data = await resp.json(); + + if (data.error) { + notify(data.error, 'error'); + return; + } + + addLog(`Format gewechselt zu ${format.toUpperCase()}`, 'success'); + notify(`Format umgeschaltet: ${format.toUpperCase()}`, 'success'); + + // Update active stream ID to new stream + state.activeStreamId = data.id; + refreshStreams(); + + // Highlight new format + document.querySelectorAll('[data-stream-format]').forEach(el => el.classList.remove('selected')); + document.querySelector(`[data-stream-format="${format}"]`)?.classList.add('selected'); + } catch (e) { + notify('Format-Wechsel fehlgeschlagen', 'error'); + } +} + +// ============ PIPELINES ============ +async function refreshPipelines() { + try { + const resp = await fetch('/api/pipelines'); + const data = await resp.json(); + state.pipelines = data.pipelines || []; + renderPipelineList(); + } catch (e) {} +} + +function renderPipelineList() { + const el = document.getElementById('pipelineList'); + if (!state.pipelines.length) { + el.innerHTML = '
Keine Pipelines vorhanden
'; + return; + } + + el.innerHTML = state.pipelines.map(p => ` +
+
+
+
${p.name}
+
${(p.stages || []).length} Stufen | Status: ${p.status}
+
+ ${p.status} +
+ + +
+
+ `).join(''); +} + +async function createPipeline() { + const name = prompt('Pipeline-Name:', 'Neue Pipeline'); + if (!name) return; + + try { + const resp = await fetch('/api/pipelines', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const data = await resp.json(); + addLog(`Pipeline erstellt: ${data.name}`, 'success'); + refreshPipelines(); + editPipeline(data.id); + } catch (e) { + notify('Pipeline-Erstellung fehlgeschlagen', 'error'); + } +} + +function editPipeline(id) { + state.activePipelineId = id; + const pipeline = state.pipelines.find(p => p.id === id); + if (!pipeline) return; + + state.pipelineStages = pipeline.stages || []; + document.getElementById('pipelineEditor').style.display = ''; + renderPipelineFlow(); +} + +function renderPipelineFlow() { + const flow = document.getElementById('pipelineFlow'); + let html = ` +
+
Input
+
Source
+
+
+ `; + + state.pipelineStages.forEach((stage, i) => { + html += `
`; + html += ` +
+
${stage.type}
+
${stage.label || stage.type}
+
+
+ `; + }); + + html += `
`; + html += ` +
+
Output
+
Target
+
+
+ `; + + flow.innerHTML = html; +} + +function toggleStage(index) { + if (state.pipelineStages[index]) { + state.pipelineStages[index].enabled = !state.pipelineStages[index].enabled; + renderPipelineFlow(); + savePipelineStages(); + } +} + +async function addPipelineStage(type) { + if (!state.activePipelineId) { + notify('Bitte zuerst eine Pipeline auswählen oder erstellen', 'warning'); + return; + } + + const stageDefaults = { + transcode: { params: { video_codec: 'libx264', preset: 'medium', crf: 23 } }, + scale: { params: { width: 1920, height: 1080 } }, + filter: { params: { brightness: 0, contrast: 1, saturation: 1 } }, + audio: { params: { codec: 'aac', bitrate: '128k', sample_rate: 44100 } }, + bitrate: { params: { video: '2M', audio: '128k' } }, + framerate: { params: { fps: 30 } }, + trim: { params: { start: '00:00:00', duration: '' } }, + deinterlace: { params: {} }, + denoise: { params: {} }, + stabilize: { params: {} }, + }; + + const defaults = stageDefaults[type] || { params: {} }; + const stage = { + type, + label: type.charAt(0).toUpperCase() + type.slice(1), + params: defaults.params, + enabled: true, + }; + + state.pipelineStages.push(stage); + renderPipelineFlow(); + await savePipelineStages(); + addLog(`Stufe hinzugefügt: ${type}`, 'info'); +} + +async function savePipelineStages() { + if (!state.activePipelineId) return; + + try { + await fetch(`/api/pipelines/${state.activePipelineId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stages: state.pipelineStages }), + }); + } catch (e) { + console.error('Failed to save pipeline stages'); + } +} + +async function deletePipeline(id) { + if (!confirm('Pipeline löschen?')) return; + await fetch(`/api/pipelines/${id}`, { method: 'DELETE' }); + if (state.activePipelineId === id) { + state.activePipelineId = null; + document.getElementById('pipelineEditor').style.display = 'none'; + } + refreshPipelines(); +} + +async function runPipeline(id) { + if (!state.uploadedFilePath) { + notify('Bitte zuerst eine Datei hochladen (Konverter-Seite)', 'warning'); + return; + } + + try { + const resp = await fetch(`/api/pipelines/${id}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + input_file: state.uploadedFilePath, + output_format: state.selectedFormat, + }), + }); + const data = await resp.json(); + if (data.error) { + notify(data.error, 'error'); + return; + } + addLog(`Pipeline ${id} ausgeführt, Job: ${data.id}`, 'success'); + notify('Pipeline gestartet', 'success'); + startJobPolling(data.id); + } catch (e) { + notify('Pipeline-Start fehlgeschlagen', 'error'); + } +} + +// ============ QUEUE ============ +async function refreshQueue() { + try { + const resp = await fetch('/api/queue'); + const data = await resp.json(); + renderQueue(data.queue || [], data.stats || {}); + } catch (e) {} +} + +function renderQueue(queue, stats) { + const el = document.getElementById('queueList'); + if (!queue.length) { + el.innerHTML = '
Warteschlange ist leer
'; + } else { + el.innerHTML = queue.map(job => ` +
+
📄
+
+
${job.input_file || job.queue_id}
+
Priorität: ${job.priority || 5} | ${job.output_format || 'mp4'}
+
+ ${job.queue_status} +
+ `).join(''); + } + + document.getElementById('queueWaiting').textContent = stats.waiting || 0; + document.getElementById('queueProcessing').textContent = stats.processing || 0; + document.getElementById('queueCompleted').textContent = stats.completed || 0; + document.getElementById('queueFailed').textContent = stats.failed || 0; +} + +async function clearQueue() { + await fetch('/api/queue', { method: 'DELETE' }); + refreshQueue(); + notify('Queue geleert', 'info'); +} + +// ============ STATUS / SYSTEM ============ +async function refreshStatus() { + try { + const resp = await fetch('/api/system'); + const data = await resp.json(); + updateGauges(data); + refreshJobs(); + } catch (e) { + document.getElementById('systemStatusDot').className = 'status-dot error'; + document.getElementById('systemStatusText').textContent = 'OFFLINE'; + } +} + +function updateGauges(data) { + // CPU + const cpuLoad = data.cpu_load?.[0] || 0; + const cpuPercent = Math.min(100, cpuLoad * 25); // Normalize to ~100% at load 4 + document.getElementById('gaugeCpu').textContent = cpuLoad.toFixed(1); + const cpuBar = document.getElementById('gaugeCpuBar'); + cpuBar.style.width = cpuPercent + '%'; + cpuBar.className = 'gauge-bar-fill' + (cpuPercent > 80 ? ' danger' : cpuPercent > 50 ? ' warning' : ''); + + // Memory + const mem = data.memory || {}; + const memPercent = mem.peak ? Math.round((mem.used / mem.peak) * 100) : 0; + document.getElementById('gaugeMem').textContent = memPercent; + const memBar = document.getElementById('gaugeMemBar'); + memBar.style.width = memPercent + '%'; + memBar.className = 'gauge-bar-fill' + (memPercent > 80 ? ' danger' : memPercent > 50 ? ' warning' : ''); + + // Disk + const diskFree = (data.disk?.free || 0) / (1024 * 1024 * 1024); + const diskTotal = (data.disk?.total || 1) / (1024 * 1024 * 1024); + const diskUsedPercent = Math.round(((diskTotal - diskFree) / diskTotal) * 100); + document.getElementById('gaugeDisk').textContent = diskFree.toFixed(1); + const diskBar = document.getElementById('gaugeDiskBar'); + diskBar.style.width = diskUsedPercent + '%'; + diskBar.className = 'gauge-bar-fill' + (diskUsedPercent > 90 ? ' danger' : diskUsedPercent > 70 ? ' warning' : ''); + + // Status + document.getElementById('systemStatusDot').className = 'status-dot'; + document.getElementById('systemStatusText').textContent = data.ffmpeg_available ? 'SYSTEM ONLINE' : 'FFMPEG MISSING'; + if (!data.ffmpeg_available) { + document.getElementById('systemStatusDot').className = 'status-dot warning'; + } +} + +function startAutoRefresh() { + if (state.refreshInterval) clearInterval(state.refreshInterval); + state.refreshInterval = setInterval(() => { + if (state.currentPage === 'dashboard') refreshStatus(); + if (state.currentPage === 'streams') refreshStreams(); + }, 5000); +} + +// ============ LOGGING ============ +function addLog(message, level = 'info') { + const console = document.getElementById('logConsole'); + const time = new Date().toLocaleTimeString(); + const line = document.createElement('div'); + line.className = `log-line ${level}`; + line.innerHTML = `[${time}] ${escapeHtml(message)}`; + console.appendChild(line); + console.scrollTop = console.scrollHeight; + + // Keep max 100 lines + while (console.children.length > 100) { + console.removeChild(console.firstChild); + } +} + +function clearLog() { + document.getElementById('logConsole').innerHTML = + '
[CLEAR] Log bereinigt
'; +} + +// ============ NOTIFICATIONS ============ +function notify(message, type = 'info') { + const el = document.getElementById('notification'); + el.className = `notification ${type} show`; + el.textContent = message; + setTimeout(() => { el.classList.remove('show'); }, 3000); +} + +// ============ HELPERS ============ +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0, size = bytes; + while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } + return size.toFixed(1) + ' ' + units[i]; +} + +function formatDuration(seconds) { + if (!seconds) return '0:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/video-converter-suite/public/router.php b/video-converter-suite/public/router.php new file mode 100644 index 0000000..61d508b --- /dev/null +++ b/video-converter-suite/public/router.php @@ -0,0 +1,15 @@ +stateFile = __DIR__ . '/../../storage/temp/jobs.json'; + $this->probe = new MediaProbe(); + $this->load(); + } + + private function load(): void + { + if (file_exists($this->stateFile)) { + $this->jobs = json_decode(file_get_contents($this->stateFile), true) ?: []; + } + } + + private function save(): void + { + $dir = dirname($this->stateFile); + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($this->stateFile, json_encode($this->jobs, JSON_PRETTY_PRINT)); + } + + public function convert(array $params): array + { + $config = require __DIR__ . '/../../config/app.php'; + + $inputFile = $params['input_file'] ?? ''; + $outputFormat = $params['output_format'] ?? 'mp4'; + $preset = $params['preset'] ?? 'balanced'; + $resolution = $params['resolution'] ?? null; + $customPipeline = $params['pipeline'] ?? null; + + if (!file_exists($inputFile)) { + return ['error' => 'Input file not found']; + } + + $id = bin2hex(random_bytes(8)); + $formatConfig = $config['formats']['video'][$outputFormat] + ?? $config['formats']['audio'][$outputFormat] + ?? null; + + if (!$formatConfig) { + return ['error' => "Unknown format: {$outputFormat}"]; + } + + $inputInfo = $this->probe->analyze($inputFile); + $baseName = pathinfo($inputFile, PATHINFO_FILENAME); + $outputFile = $config['storage']['outputs'] . "/{$baseName}_{$id}.{$formatConfig['ext']}"; + + // Build command + if ($customPipeline instanceof Pipeline) { + $cmd = $customPipeline->buildFFmpegCommand($inputFile, $outputFile); + } else { + $cmd = $this->buildCommand($inputFile, $outputFile, $outputFormat, $preset, $resolution, $params); + } + + $process = new FFmpegProcess($cmd, $id); + if (isset($inputInfo['duration'])) { + $process->setDuration($inputInfo['duration']); + } + + // Generate thumbnail + $thumbPath = $config['storage']['thumbnails'] . "/{$id}.jpg"; + $this->probe->getThumbnail($inputFile, $thumbPath); + + $job = [ + 'id' => $id, + 'input_file' => $inputFile, + 'input_info' => $inputInfo, + 'output_file' => $outputFile, + 'output_format' => $outputFormat, + 'preset' => $preset, + 'resolution' => $resolution, + 'thumbnail' => file_exists($thumbPath) ? $thumbPath : null, + 'status' => 'starting', + 'pid' => null, + 'command' => $cmd, + 'created_at' => date('c'), + ]; + + if ($process->start()) { + $job['status'] = 'running'; + $job['pid'] = $process->getPid(); + } else { + $job['status'] = 'error'; + $job['error'] = 'Failed to start FFmpeg process'; + } + + $this->jobs[$id] = $job; + $this->save(); + + return $job; + } + + public function batchConvert(string $inputFile, array $formats): array + { + $results = []; + foreach ($formats as $format => $settings) { + $params = array_merge( + ['input_file' => $inputFile, 'output_format' => $format], + $settings + ); + $results[$format] = $this->convert($params); + } + return $results; + } + + public function getJob(string $id): ?array + { + $this->refreshJob($id); + return $this->jobs[$id] ?? null; + } + + public function getAllJobs(): array + { + foreach (array_keys($this->jobs) as $id) { + $this->refreshJob($id); + } + return array_values($this->jobs); + } + + public function cancelJob(string $id): bool + { + if (!isset($this->jobs[$id])) return false; + + $job = $this->jobs[$id]; + if ($job['pid'] && $job['status'] === 'running') { + posix_kill($job['pid'], SIGTERM); + $this->jobs[$id]['status'] = 'cancelled'; + $this->save(); + return true; + } + return false; + } + + public function deleteJob(string $id): bool + { + if (isset($this->jobs[$id])) { + $this->cancelJob($id); + // Clean up output file + if (isset($this->jobs[$id]['output_file']) && file_exists($this->jobs[$id]['output_file'])) { + unlink($this->jobs[$id]['output_file']); + } + unset($this->jobs[$id]); + $this->save(); + return true; + } + return false; + } + + public function getProgress(string $id): array + { + if (!isset($this->jobs[$id])) { + return ['error' => 'Job not found']; + } + + $config = require __DIR__ . '/../../config/app.php'; + $progressFile = $config['storage']['logs'] . "/progress_{$id}.txt"; + + $progress = ['percent' => 0, 'fps' => 0, 'speed' => '0x', 'time' => '00:00:00']; + + if (file_exists($progressFile)) { + $content = file_get_contents($progressFile); + foreach (explode("\n", $content) as $line) { + if (str_contains($line, '=')) { + [$key, $val] = explode('=', $line, 2); + $key = trim($key); + $val = trim($val); + if ($key === 'out_time') $progress['time'] = $val; + if ($key === 'fps') $progress['fps'] = (float)$val; + if ($key === 'speed') $progress['speed'] = $val; + if ($key === 'progress' && $val === 'end') $progress['percent'] = 100; + } + } + + $duration = $this->jobs[$id]['input_info']['duration'] ?? 0; + if ($duration > 0 && $progress['percent'] < 100) { + $current = $this->timeToSeconds($progress['time']); + $progress['percent'] = min(99, round(($current / $duration) * 100, 1)); + } + } + + return $progress; + } + + private function refreshJob(string $id): void + { + if (!isset($this->jobs[$id])) return; + $job = &$this->jobs[$id]; + + if ($job['status'] === 'running' && $job['pid']) { + if (!posix_kill($job['pid'], 0)) { + // Check if output file exists and has size + if (isset($job['output_file']) && file_exists($job['output_file']) && filesize($job['output_file']) > 0) { + $job['status'] = 'completed'; + $job['completed_at'] = date('c'); + $job['output_size'] = filesize($job['output_file']); + } else { + $job['status'] = 'error'; + $job['error'] = 'Process ended without output'; + } + $this->save(); + } + } + } + + private function buildCommand(string $input, string $output, string $format, string $preset, ?string $resolution, array $params): string + { + $config = require __DIR__ . '/../../config/app.php'; + $ffmpeg = $config['ffmpeg']['binary']; + $formatConfig = $config['formats']['video'][$format] ?? $config['formats']['audio'][$format] ?? []; + $presetConfig = $config['presets'][$preset] ?? $config['presets']['balanced']; + $threads = $config['ffmpeg']['threads']; + + $cmd = "{$ffmpeg} -y -i " . escapeshellarg($input); + $cmd .= " -threads {$threads}"; + + // Check if audio-only + $isAudio = isset($config['formats']['audio'][$format]); + + if ($isAudio) { + $cmd .= " -vn"; + $cmd .= " -c:a " . escapeshellarg($formatConfig['codec']); + if (isset($params['audio_bitrate'])) { + $cmd .= " -b:a " . escapeshellarg($params['audio_bitrate']); + } + } else { + $cmd .= " -c:v " . escapeshellarg($formatConfig['codec']); + $cmd .= " -preset " . escapeshellarg($presetConfig['preset']); + $cmd .= " -crf " . (int)$presetConfig['crf']; + + if ($resolution && isset($config['resolutions'][$resolution])) { + $res = $config['resolutions'][$resolution]; + $cmd .= " -vf scale={$res['width']}:{$res['height']}"; + } + + $cmd .= " -c:a aac -b:a 128k"; + } + + // HLS specific + if ($format === 'hls') { + $cmd .= " -hls_time 4 -hls_list_size 0 -hls_segment_filename " + . escapeshellarg(dirname($output) . "/segment_%03d.ts"); + } + + // DASH specific + if ($format === 'dash') { + $cmd .= " -use_timeline 1 -use_template 1 -adaptation_sets 'id=0,streams=v id=1,streams=a'"; + } + + // Extra params + if (isset($params['video_bitrate'])) { + $cmd .= " -b:v " . escapeshellarg($params['video_bitrate']); + } + if (isset($params['fps'])) { + $cmd .= " -r " . (int)$params['fps']; + } + + $cmd .= " " . escapeshellarg($output); + return $cmd; + } + + private function timeToSeconds(string $time): float + { + $parts = explode(':', $time); + if (count($parts) !== 3) return 0; + return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2]; + } +} diff --git a/video-converter-suite/src/Pipeline/Pipeline.php b/video-converter-suite/src/Pipeline/Pipeline.php new file mode 100644 index 0000000..0fcfe67 --- /dev/null +++ b/video-converter-suite/src/Pipeline/Pipeline.php @@ -0,0 +1,127 @@ +id = $id ?? bin2hex(random_bytes(8)); + $this->name = $name; + $this->createdAt = date('c'); + } + + public function getId(): string { return $this->id; } + public function getName(): string { return $this->name; } + public function getStatus(): string { return $this->status; } + public function getProgress(): float { return $this->progress; } + public function getPid(): ?int { return $this->pid; } + public function getStages(): array { return $this->stages; } + public function getInputSource(): ?string { return $this->inputSource; } + + public function setStatus(string $status): void { $this->status = $status; } + public function setProgress(float $progress): void { $this->progress = min(100, max(0, $progress)); } + public function setPid(?int $pid): void { $this->pid = $pid; } + public function setInputSource(string $source): void { $this->inputSource = $source; } + + public function addStage(PipelineStage $stage): self + { + $this->stages[] = $stage; + return $this; + } + + public function removeStage(int $index): self + { + if (isset($this->stages[$index])) { + array_splice($this->stages, $index, 1); + } + return $this; + } + + public function insertStage(int $index, PipelineStage $stage): self + { + array_splice($this->stages, $index, 0, [$stage]); + return $this; + } + + public function setMetadata(string $key, mixed $value): void + { + $this->metadata[$key] = $value; + } + + public function getMetadata(?string $key = null): mixed + { + if ($key === null) return $this->metadata; + return $this->metadata[$key] ?? null; + } + + public function buildFFmpegCommand(string $inputPath, string $outputPath): string + { + $config = require __DIR__ . '/../../config/app.php'; + $cmd = $config['ffmpeg']['binary']; + $parts = ["-y -i " . escapeshellarg($inputPath)]; + + foreach ($this->stages as $stage) { + $parts[] = $stage->toFFmpegArgs(); + } + + $parts[] = escapeshellarg($outputPath); + return $cmd . ' ' . implode(' ', $parts); + } + + public function buildStreamCommand(string $inputUrl, string $outputUrl): string + { + $config = require __DIR__ . '/../../config/app.php'; + $cmd = $config['ffmpeg']['binary']; + $parts = ["-re -i " . escapeshellarg($inputUrl)]; + + foreach ($this->stages as $stage) { + $parts[] = $stage->toFFmpegArgs(); + } + + $parts[] = "-f flv " . escapeshellarg($outputUrl); + return $cmd . ' ' . implode(' ', $parts); + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'status' => $this->status, + 'progress' => $this->progress, + 'pid' => $this->pid, + 'input_source' => $this->inputSource, + 'stages' => array_map(fn(PipelineStage $s) => $s->toArray(), $this->stages), + 'metadata' => $this->metadata, + 'created_at' => $this->createdAt, + ]; + } + + public static function fromArray(array $data): self + { + $pipeline = new self($data['name'], $data['id']); + $pipeline->status = $data['status'] ?? 'idle'; + $pipeline->progress = $data['progress'] ?? 0; + $pipeline->pid = $data['pid'] ?? null; + $pipeline->inputSource = $data['input_source'] ?? null; + $pipeline->metadata = $data['metadata'] ?? []; + $pipeline->createdAt = $data['created_at'] ?? date('c'); + + foreach (($data['stages'] ?? []) as $stageData) { + $pipeline->addStage(PipelineStage::fromArray($stageData)); + } + + return $pipeline; + } +} diff --git a/video-converter-suite/src/Pipeline/PipelineManager.php b/video-converter-suite/src/Pipeline/PipelineManager.php new file mode 100644 index 0000000..af78cea --- /dev/null +++ b/video-converter-suite/src/Pipeline/PipelineManager.php @@ -0,0 +1,89 @@ +stateFile = __DIR__ . '/../../storage/temp/pipelines.json'; + $this->load(); + } + + private function load(): void + { + if (file_exists($this->stateFile)) { + $data = json_decode(file_get_contents($this->stateFile), true); + foreach (($data['pipelines'] ?? []) as $pData) { + $this->pipelines[$pData['id']] = Pipeline::fromArray($pData); + } + } + } + + public function save(): void + { + $dir = dirname($this->stateFile); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + $data = ['pipelines' => []]; + foreach ($this->pipelines as $pipeline) { + $data['pipelines'][] = $pipeline->toArray(); + } + file_put_contents($this->stateFile, json_encode($data, JSON_PRETTY_PRINT)); + } + + public function create(string $name): Pipeline + { + $pipeline = new Pipeline($name); + $this->pipelines[$pipeline->getId()] = $pipeline; + $this->save(); + return $pipeline; + } + + public function get(string $id): ?Pipeline + { + return $this->pipelines[$id] ?? null; + } + + public function getAll(): array + { + return $this->pipelines; + } + + public function delete(string $id): bool + { + if (isset($this->pipelines[$id])) { + $pipeline = $this->pipelines[$id]; + if ($pipeline->getStatus() === 'running' && $pipeline->getPid()) { + posix_kill($pipeline->getPid(), SIGTERM); + } + unset($this->pipelines[$id]); + $this->save(); + return true; + } + return false; + } + + public function getRunningCount(): int + { + $count = 0; + foreach ($this->pipelines as $p) { + if ($p->getStatus() === 'running') $count++; + } + return $count; + } + + public function getByStatus(string $status): array + { + return array_filter($this->pipelines, fn(Pipeline $p) => $p->getStatus() === $status); + } + + public function toArray(): array + { + return array_map(fn(Pipeline $p) => $p->toArray(), array_values($this->pipelines)); + } +} diff --git a/video-converter-suite/src/Pipeline/PipelineStage.php b/video-converter-suite/src/Pipeline/PipelineStage.php new file mode 100644 index 0000000..8b1a04f --- /dev/null +++ b/video-converter-suite/src/Pipeline/PipelineStage.php @@ -0,0 +1,197 @@ +id = bin2hex(random_bytes(4)); + $this->type = $type; + $this->params = $params; + $this->label = $label ?: ucfirst($type); + $this->enabled = $enabled; + } + + public function getId(): string { return $this->id; } + public function getType(): string { return $this->type; } + public function getParams(): array { return $this->params; } + public function isEnabled(): bool { return $this->enabled; } + public function getLabel(): string { return $this->label; } + + public function setEnabled(bool $enabled): void { $this->enabled = $enabled; } + public function setParams(array $params): void { $this->params = $params; } + + public function toFFmpegArgs(): string + { + if (!$this->enabled) return ''; + + return match ($this->type) { + 'transcode' => $this->buildTranscodeArgs(), + 'scale' => $this->buildScaleArgs(), + 'filter' => $this->buildFilterArgs(), + 'audio' => $this->buildAudioArgs(), + 'watermark' => $this->buildWatermarkArgs(), + 'trim' => $this->buildTrimArgs(), + 'bitrate' => $this->buildBitrateArgs(), + 'framerate' => $this->buildFramerateArgs(), + 'deinterlace' => '-vf yadif', + 'denoise' => '-vf hqdn3d', + 'stabilize' => '-vf deshake', + default => '', + }; + } + + private function buildTranscodeArgs(): string + { + $args = []; + if (isset($this->params['video_codec'])) { + $args[] = "-c:v " . escapeshellarg($this->params['video_codec']); + } + if (isset($this->params['audio_codec'])) { + $args[] = "-c:a " . escapeshellarg($this->params['audio_codec']); + } + if (isset($this->params['preset'])) { + $args[] = "-preset " . escapeshellarg($this->params['preset']); + } + if (isset($this->params['crf'])) { + $args[] = "-crf " . (int)$this->params['crf']; + } + return implode(' ', $args); + } + + private function buildScaleArgs(): string + { + $w = (int)($this->params['width'] ?? -1); + $h = (int)($this->params['height'] ?? -1); + $algo = $this->params['algorithm'] ?? 'lanczos'; + return "-vf scale={$w}:{$h}:flags={$algo}"; + } + + private function buildFilterArgs(): string + { + $filters = []; + if (isset($this->params['brightness'])) { + $filters[] = "eq=brightness=" . (float)$this->params['brightness']; + } + if (isset($this->params['contrast'])) { + $filters[] = "eq=contrast=" . (float)$this->params['contrast']; + } + if (isset($this->params['saturation'])) { + $filters[] = "eq=saturation=" . (float)$this->params['saturation']; + } + if (isset($this->params['gamma'])) { + $filters[] = "eq=gamma=" . (float)$this->params['gamma']; + } + if (isset($this->params['custom'])) { + $filters[] = $this->params['custom']; + } + return $filters ? '-vf ' . escapeshellarg(implode(',', $filters)) : ''; + } + + private function buildAudioArgs(): string + { + $args = []; + if (isset($this->params['codec'])) { + $args[] = "-c:a " . escapeshellarg($this->params['codec']); + } + if (isset($this->params['bitrate'])) { + $args[] = "-b:a " . escapeshellarg($this->params['bitrate']); + } + if (isset($this->params['sample_rate'])) { + $args[] = "-ar " . (int)$this->params['sample_rate']; + } + if (isset($this->params['channels'])) { + $args[] = "-ac " . (int)$this->params['channels']; + } + if (isset($this->params['volume'])) { + $args[] = "-af volume=" . (float)$this->params['volume']; + } + return implode(' ', $args); + } + + private function buildWatermarkArgs(): string + { + $image = $this->params['image'] ?? ''; + $position = $this->params['position'] ?? 'topright'; + $overlay = match ($position) { + 'topleft' => 'overlay=10:10', + 'topright' => 'overlay=W-w-10:10', + 'bottomleft' => 'overlay=10:H-h-10', + 'bottomright' => 'overlay=W-w-10:H-h-10', + 'center' => 'overlay=(W-w)/2:(H-h)/2', + default => 'overlay=W-w-10:10', + }; + return "-i " . escapeshellarg($image) . " -filter_complex \"{$overlay}\""; + } + + private function buildTrimArgs(): string + { + $args = []; + if (isset($this->params['start'])) { + $args[] = "-ss " . escapeshellarg($this->params['start']); + } + if (isset($this->params['duration'])) { + $args[] = "-t " . escapeshellarg($this->params['duration']); + } + if (isset($this->params['end'])) { + $args[] = "-to " . escapeshellarg($this->params['end']); + } + return implode(' ', $args); + } + + private function buildBitrateArgs(): string + { + $args = []; + if (isset($this->params['video'])) { + $args[] = "-b:v " . escapeshellarg($this->params['video']); + } + if (isset($this->params['audio'])) { + $args[] = "-b:a " . escapeshellarg($this->params['audio']); + } + if (isset($this->params['maxrate'])) { + $args[] = "-maxrate " . escapeshellarg($this->params['maxrate']); + $args[] = "-bufsize " . escapeshellarg($this->params['bufsize'] ?? $this->params['maxrate']); + } + return implode(' ', $args); + } + + private function buildFramerateArgs(): string + { + $fps = (float)($this->params['fps'] ?? 30); + return "-r {$fps}"; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'label' => $this->label, + 'params' => $this->params, + 'enabled' => $this->enabled, + ]; + } + + public static function fromArray(array $data): self + { + $stage = new self( + $data['type'], + $data['params'] ?? [], + $data['label'] ?? '', + $data['enabled'] ?? true + ); + if (isset($data['id'])) { + // Use reflection to set the id for deserialization + $ref = new \ReflectionProperty($stage, 'id'); + $ref->setValue($stage, $data['id']); + } + return $stage; + } +} diff --git a/video-converter-suite/src/Process/FFmpegProcess.php b/video-converter-suite/src/Process/FFmpegProcess.php new file mode 100644 index 0000000..56d4705 --- /dev/null +++ b/video-converter-suite/src/Process/FFmpegProcess.php @@ -0,0 +1,159 @@ +command = $command; + $logDir = $config['storage']['logs']; + if (!is_dir($logDir)) mkdir($logDir, 0755, true); + $this->logFile = $logDir . "/ffmpeg_{$jobId}.log"; + $this->progressFile = $logDir . "/progress_{$jobId}.txt"; + } + + public function start(): bool + { + $cmd = $this->command + . " -progress " . escapeshellarg($this->progressFile) + . " -stats_period 0.5" + . " 2>" . escapeshellarg($this->logFile) + . " & echo $!"; + + $output = []; + exec($cmd, $output); + $this->pid = (int)($output[0] ?? 0); + + if ($this->pid > 0) { + $this->status = 'running'; + return true; + } + $this->status = 'error'; + return false; + } + + public function stop(): void + { + if ($this->pid && $this->isRunning()) { + posix_kill($this->pid, SIGTERM); + usleep(500000); + if ($this->isRunning()) { + posix_kill($this->pid, SIGKILL); + } + } + $this->status = 'stopped'; + } + + public function pause(): void + { + if ($this->pid && $this->isRunning()) { + posix_kill($this->pid, SIGSTOP); + $this->status = 'paused'; + } + } + + public function resume(): void + { + if ($this->pid) { + posix_kill($this->pid, SIGCONT); + $this->status = 'running'; + } + } + + public function isRunning(): bool + { + if (!$this->pid) return false; + return posix_kill($this->pid, 0); + } + + public function getProgress(): array + { + $progress = [ + 'percent' => 0, + 'frame' => 0, + 'fps' => 0, + 'speed' => '0x', + 'time' => '00:00:00.00', + 'bitrate' => '0kbits/s', + 'size' => '0kB', + ]; + + if (!file_exists($this->progressFile)) return $progress; + + $content = file_get_contents($this->progressFile); + $lines = explode("\n", $content); + + foreach ($lines as $line) { + $line = trim($line); + if (str_contains($line, '=')) { + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + $value = trim($value); + + switch ($key) { + case 'frame': $progress['frame'] = (int)$value; break; + case 'fps': $progress['fps'] = (float)$value; break; + case 'speed': $progress['speed'] = $value; break; + case 'out_time': $progress['time'] = $value; break; + case 'total_size': $progress['size'] = $this->formatBytes((int)$value); break; + case 'bitrate': $progress['bitrate'] = $value; break; + case 'progress': + if ($value === 'end') $progress['percent'] = 100; + break; + } + } + } + + if ($this->duration > 0 && $progress['percent'] < 100) { + $currentTime = $this->timeToSeconds($progress['time']); + $progress['percent'] = min(99, round(($currentTime / $this->duration) * 100, 1)); + } + + return $progress; + } + + public function getLog(int $lines = 50): string + { + if (!file_exists($this->logFile)) return ''; + $all = file($this->logFile); + return implode('', array_slice($all, -$lines)); + } + + public function setDuration(float $duration): void + { + $this->duration = $duration; + } + + public function getPid(): ?int { return $this->pid; } + public function getStatus(): string { return $this->status; } + public function getCommand(): string { return $this->command; } + + private function timeToSeconds(string $time): float + { + $parts = explode(':', $time); + if (count($parts) !== 3) return 0; + return (int)$parts[0] * 3600 + (int)$parts[1] * 60 + (float)$parts[2]; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + $size = (float)$bytes; + while ($size >= 1024 && $i < count($units) - 1) { + $size /= 1024; + $i++; + } + return round($size, 1) . $units[$i]; + } +} diff --git a/video-converter-suite/src/Process/MediaProbe.php b/video-converter-suite/src/Process/MediaProbe.php new file mode 100644 index 0000000..32b60c1 --- /dev/null +++ b/video-converter-suite/src/Process/MediaProbe.php @@ -0,0 +1,106 @@ +ffprobe = $config['ffmpeg']['ffprobe']; + } + + public function analyze(string $filePath): array + { + $cmd = sprintf( + '%s -v quiet -print_format json -show_format -show_streams %s', + $this->ffprobe, + escapeshellarg($filePath) + ); + + $output = shell_exec($cmd); + $data = json_decode($output ?: '{}', true); + + if (!$data) { + return ['error' => 'Could not analyze file']; + } + + return $this->parseProbeData($data); + } + + public function getDuration(string $filePath): float + { + $info = $this->analyze($filePath); + return (float)($info['duration'] ?? 0); + } + + public function getThumbnail(string $filePath, string $outputPath, string $time = '00:00:01'): bool + { + $config = require __DIR__ . '/../../config/app.php'; + $cmd = sprintf( + '%s -y -i %s -ss %s -vframes 1 -vf scale=320:-1 %s 2>/dev/null', + $config['ffmpeg']['binary'], + escapeshellarg($filePath), + escapeshellarg($time), + escapeshellarg($outputPath) + ); + exec($cmd, $output, $exitCode); + return $exitCode === 0; + } + + private function parseProbeData(array $data): array + { + $result = [ + 'format' => $data['format']['format_long_name'] ?? 'Unknown', + 'format_name' => $data['format']['format_name'] ?? '', + 'duration' => (float)($data['format']['duration'] ?? 0), + 'size' => (int)($data['format']['size'] ?? 0), + 'bitrate' => (int)($data['format']['bit_rate'] ?? 0), + 'streams' => [], + 'video' => null, + 'audio' => null, + ]; + + foreach (($data['streams'] ?? []) as $stream) { + $type = $stream['codec_type'] ?? ''; + $info = [ + 'index' => $stream['index'], + 'type' => $type, + 'codec' => $stream['codec_name'] ?? 'unknown', + 'codec_long' => $stream['codec_long_name'] ?? '', + ]; + + if ($type === 'video') { + $info['width'] = (int)($stream['width'] ?? 0); + $info['height'] = (int)($stream['height'] ?? 0); + $info['fps'] = $this->parseFps($stream['r_frame_rate'] ?? '0/1'); + $info['pix_fmt'] = $stream['pix_fmt'] ?? ''; + $info['bitrate'] = (int)($stream['bit_rate'] ?? 0); + $info['profile'] = $stream['profile'] ?? ''; + $info['level'] = $stream['level'] ?? ''; + if (!$result['video']) $result['video'] = $info; + } elseif ($type === 'audio') { + $info['sample_rate'] = (int)($stream['sample_rate'] ?? 0); + $info['channels'] = (int)($stream['channels'] ?? 0); + $info['channel_layout'] = $stream['channel_layout'] ?? ''; + $info['bitrate'] = (int)($stream['bit_rate'] ?? 0); + if (!$result['audio']) $result['audio'] = $info; + } + + $result['streams'][] = $info; + } + + return $result; + } + + private function parseFps(string $frac): float + { + $parts = explode('/', $frac); + if (count($parts) === 2 && (int)$parts[1] > 0) { + return round((int)$parts[0] / (int)$parts[1], 2); + } + return (float)$frac; + } +} diff --git a/video-converter-suite/src/Queue/JobQueue.php b/video-converter-suite/src/Queue/JobQueue.php new file mode 100644 index 0000000..c95fb8c --- /dev/null +++ b/video-converter-suite/src/Queue/JobQueue.php @@ -0,0 +1,115 @@ +queueFile = __DIR__ . '/../../storage/temp/queue.json'; + $this->load(); + } + + private function load(): void + { + if (file_exists($this->queueFile)) { + $this->queue = json_decode(file_get_contents($this->queueFile), true) ?: []; + } + } + + private function save(): void + { + $dir = dirname($this->queueFile); + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($this->queueFile, json_encode($this->queue, JSON_PRETTY_PRINT)); + } + + public function enqueue(array $job): string + { + $id = bin2hex(random_bytes(8)); + $job['queue_id'] = $id; + $job['queued_at'] = date('c'); + $job['queue_status'] = 'waiting'; + $job['priority'] = $job['priority'] ?? 5; + + $this->queue[] = $job; + + // Sort by priority (lower = higher priority) + usort($this->queue, fn($a, $b) => ($a['priority'] ?? 5) <=> ($b['priority'] ?? 5)); + + $this->save(); + return $id; + } + + public function dequeue(): ?array + { + foreach ($this->queue as &$job) { + if ($job['queue_status'] === 'waiting') { + $job['queue_status'] = 'processing'; + $job['started_at'] = date('c'); + $this->save(); + return $job; + } + } + return null; + } + + public function complete(string $queueId, array $result = []): void + { + foreach ($this->queue as &$job) { + if ($job['queue_id'] === $queueId) { + $job['queue_status'] = 'completed'; + $job['completed_at'] = date('c'); + $job['result'] = $result; + break; + } + } + $this->save(); + } + + public function fail(string $queueId, string $error): void + { + foreach ($this->queue as &$job) { + if ($job['queue_id'] === $queueId) { + $job['queue_status'] = 'failed'; + $job['failed_at'] = date('c'); + $job['error'] = $error; + break; + } + } + $this->save(); + } + + public function getQueue(): array { return $this->queue; } + + public function getWaiting(): array + { + return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'waiting')); + } + + public function getProcessing(): array + { + return array_values(array_filter($this->queue, fn($j) => $j['queue_status'] === 'processing')); + } + + public function clear(string $status = 'completed'): int + { + $before = count($this->queue); + $this->queue = array_values(array_filter($this->queue, fn($j) => $j['queue_status'] !== $status)); + $this->save(); + return $before - count($this->queue); + } + + public function getStats(): array + { + $stats = ['waiting' => 0, 'processing' => 0, 'completed' => 0, 'failed' => 0]; + foreach ($this->queue as $job) { + $status = $job['queue_status'] ?? 'waiting'; + $stats[$status] = ($stats[$status] ?? 0) + 1; + } + return $stats; + } +} diff --git a/video-converter-suite/src/Stream/StreamManager.php b/video-converter-suite/src/Stream/StreamManager.php new file mode 100644 index 0000000..6140c96 --- /dev/null +++ b/video-converter-suite/src/Stream/StreamManager.php @@ -0,0 +1,197 @@ +stateFile = __DIR__ . '/../../storage/temp/streams.json'; + $this->load(); + } + + private function load(): void + { + if (file_exists($this->stateFile)) { + $this->activeStreams = json_decode(file_get_contents($this->stateFile), true) ?: []; + } + } + + private function save(): void + { + $dir = dirname($this->stateFile); + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($this->stateFile, json_encode($this->activeStreams, JSON_PRETTY_PRINT)); + } + + public function startStream(array $params): array + { + $id = bin2hex(random_bytes(8)); + $config = require __DIR__ . '/../../config/app.php'; + + $inputUrl = $params['input_url'] ?? ''; + $outputFormat = $params['output_format'] ?? 'mp4'; + $resolution = $params['resolution'] ?? null; + $preset = $params['preset'] ?? 'fast'; + + $formatConfig = $config['formats']['video'][$outputFormat] ?? $config['formats']['video']['mp4']; + $presetConfig = $config['presets'][$preset] ?? $config['presets']['fast']; + + $outputDir = $config['storage']['outputs']; + $outputFile = "{$outputDir}/stream_{$id}.{$formatConfig['ext']}"; + + $cmd = $config['ffmpeg']['binary'] . " -y"; + + // Input + if (str_starts_with($inputUrl, 'rtmp://') || str_starts_with($inputUrl, 'rtsp://')) { + $cmd .= " -re"; + } + $cmd .= " -i " . escapeshellarg($inputUrl); + + // Video codec + $cmd .= " -c:v " . escapeshellarg($formatConfig['codec']); + $cmd .= " -preset " . escapeshellarg($presetConfig['preset']); + $cmd .= " -crf " . (int)$presetConfig['crf']; + + // Resolution + if ($resolution && isset($config['resolutions'][$resolution])) { + $res = $config['resolutions'][$resolution]; + $cmd .= " -vf scale={$res['width']}:{$res['height']}"; + } + + // Audio + $audioCodec = $params['audio_codec'] ?? 'aac'; + $audioBitrate = $params['audio_bitrate'] ?? '128k'; + $cmd .= " -c:a " . escapeshellarg($audioCodec); + $cmd .= " -b:a " . escapeshellarg($audioBitrate); + + $cmd .= " " . escapeshellarg($outputFile); + + $process = new FFmpegProcess($cmd, $id); + + $stream = [ + 'id' => $id, + 'input_url' => $inputUrl, + 'output_file' => $outputFile, + 'output_format' => $outputFormat, + 'resolution' => $resolution, + 'preset' => $preset, + 'status' => 'starting', + 'pid' => null, + 'command' => $cmd, + 'started_at' => date('c'), + ]; + + if ($process->start()) { + $stream['status'] = 'running'; + $stream['pid'] = $process->getPid(); + } else { + $stream['status'] = 'error'; + } + + $this->activeStreams[$id] = $stream; + $this->save(); + + return $stream; + } + + public function stopStream(string $id): bool + { + if (!isset($this->activeStreams[$id])) return false; + + $stream = $this->activeStreams[$id]; + if ($stream['pid']) { + posix_kill($stream['pid'], SIGTERM); + usleep(500000); + if (posix_kill($stream['pid'], 0)) { + posix_kill($stream['pid'], SIGKILL); + } + } + + $this->activeStreams[$id]['status'] = 'stopped'; + $this->activeStreams[$id]['stopped_at'] = date('c'); + $this->save(); + return true; + } + + public function switchFormat(string $id, string $newFormat, ?string $resolution = null): array + { + if (!isset($this->activeStreams[$id])) { + return ['error' => 'Stream not found']; + } + + $oldStream = $this->activeStreams[$id]; + $this->stopStream($id); + + // Start new stream with same input but different output format + return $this->startStream([ + 'input_url' => $oldStream['input_url'], + 'output_format' => $newFormat, + 'resolution' => $resolution ?? $oldStream['resolution'], + 'preset' => $oldStream['preset'], + 'audio_codec' => 'aac', + ]); + } + + public function getStream(string $id): ?array + { + $this->refreshStatus($id); + return $this->activeStreams[$id] ?? null; + } + + public function getAllStreams(): array + { + foreach (array_keys($this->activeStreams) as $id) { + $this->refreshStatus($id); + } + return array_values($this->activeStreams); + } + + public function getActiveStreams(): array + { + return array_values(array_filter($this->getAllStreams(), fn($s) => $s['status'] === 'running')); + } + + private function refreshStatus(string $id): void + { + if (!isset($this->activeStreams[$id])) return; + $stream = &$this->activeStreams[$id]; + + if ($stream['status'] === 'running' && $stream['pid']) { + if (!posix_kill($stream['pid'], 0)) { + $stream['status'] = 'completed'; + $stream['completed_at'] = date('c'); + $this->save(); + } + } + } + + public function deleteStream(string $id): bool + { + if (isset($this->activeStreams[$id])) { + if ($this->activeStreams[$id]['status'] === 'running') { + $this->stopStream($id); + } + unset($this->activeStreams[$id]); + $this->save(); + return true; + } + return false; + } + + public function getStats(): array + { + $all = $this->getAllStreams(); + return [ + 'total' => count($all), + 'running' => count(array_filter($all, fn($s) => $s['status'] === 'running')), + 'completed' => count(array_filter($all, fn($s) => $s['status'] === 'completed')), + 'errors' => count(array_filter($all, fn($s) => $s['status'] === 'error')), + ]; + } +} diff --git a/video-converter-suite/src/WebSocket/StatusServer.php b/video-converter-suite/src/WebSocket/StatusServer.php new file mode 100644 index 0000000..02e3095 --- /dev/null +++ b/video-converter-suite/src/WebSocket/StatusServer.php @@ -0,0 +1,182 @@ +clients = new \SplObjectStorage(); + $this->converter = new FormatConverter(); + $this->streamManager = new StreamManager(); + $this->pipelineManager = new PipelineManager(); + $this->queue = new JobQueue(); + } + + public function onOpen(ConnectionInterface $conn): void + { + $this->clients->attach($conn); + $conn->send(json_encode([ + 'type' => 'connected', + 'message' => 'Connected to Video Converter Suite', + 'client_id' => spl_object_id($conn), + ])); + } + + public function onMessage(ConnectionInterface $from, $msg): void + { + $data = json_decode($msg, true); + if (!$data || !isset($data['action'])) return; + + $response = match ($data['action']) { + 'get_status' => $this->getFullStatus(), + 'get_jobs' => ['type' => 'jobs', 'data' => $this->converter->getAllJobs()], + 'get_streams' => ['type' => 'streams', 'data' => $this->streamManager->getAllStreams()], + 'get_pipelines' => ['type' => 'pipelines', 'data' => $this->pipelineManager->toArray()], + 'get_queue' => ['type' => 'queue', 'data' => $this->queue->getQueue()], + 'get_progress' => $this->getJobProgress($data['job_id'] ?? ''), + 'start_stream' => $this->handleStartStream($data), + 'stop_stream' => $this->handleStopStream($data['stream_id'] ?? ''), + 'switch_format' => $this->handleSwitchFormat($data), + default => ['type' => 'error', 'message' => 'Unknown action'], + }; + + $from->send(json_encode($response)); + } + + public function onClose(ConnectionInterface $conn): void + { + $this->clients->detach($conn); + } + + public function onError(ConnectionInterface $conn, \Exception $e): void + { + $conn->send(json_encode([ + 'type' => 'error', + 'message' => $e->getMessage(), + ])); + $conn->close(); + } + + public function broadcastStatus(): void + { + $status = $this->getFullStatus(); + $json = json_encode($status); + + foreach ($this->clients as $client) { + $client->send($json); + } + } + + private function getFullStatus(): array + { + // Reload state + $this->converter = new FormatConverter(); + $this->streamManager = new StreamManager(); + $this->pipelineManager = new PipelineManager(); + $this->queue = new JobQueue(); + + $jobs = $this->converter->getAllJobs(); + $runningJobs = array_filter($jobs, fn($j) => $j['status'] === 'running'); + + $progressData = []; + foreach ($runningJobs as $job) { + $progressData[$job['id']] = $this->converter->getProgress($job['id']); + } + + return [ + 'type' => 'status', + 'timestamp' => date('c'), + 'system' => $this->getSystemStats(), + 'jobs' => $jobs, + 'progress' => $progressData, + 'streams' => $this->streamManager->getAllStreams(), + 'pipelines' => $this->pipelineManager->toArray(), + 'queue' => $this->queue->getStats(), + ]; + } + + private function getJobProgress(string $jobId): array + { + $this->converter = new FormatConverter(); + return [ + 'type' => 'progress', + 'job_id' => $jobId, + 'data' => $this->converter->getProgress($jobId), + ]; + } + + private function handleStartStream(array $data): array + { + $this->streamManager = new StreamManager(); + $result = $this->streamManager->startStream($data); + return ['type' => 'stream_started', 'data' => $result]; + } + + private function handleStopStream(string $streamId): array + { + $this->streamManager = new StreamManager(); + $success = $this->streamManager->stopStream($streamId); + return ['type' => 'stream_stopped', 'success' => $success, 'stream_id' => $streamId]; + } + + private function handleSwitchFormat(array $data): array + { + $this->streamManager = new StreamManager(); + $result = $this->streamManager->switchFormat( + $data['stream_id'] ?? '', + $data['format'] ?? 'mp4', + $data['resolution'] ?? null + ); + return ['type' => 'format_switched', 'data' => $result]; + } + + private function getSystemStats(): array + { + $load = sys_getloadavg(); + $memInfo = $this->getMemoryInfo(); + + return [ + 'cpu_load' => $load[0] ?? 0, + 'memory_used' => $memInfo['used'] ?? 0, + 'memory_total' => $memInfo['total'] ?? 0, + 'memory_percent' => $memInfo['percent'] ?? 0, + 'disk_free' => disk_free_space('/'), + 'disk_total' => disk_total_space('/'), + 'uptime' => (int)(file_exists('/proc/uptime') + ? (float)explode(' ', file_get_contents('/proc/uptime'))[0] + : 0), + ]; + } + + private function getMemoryInfo(): array + { + if (!file_exists('/proc/meminfo')) { + return ['total' => 0, 'used' => 0, 'percent' => 0]; + } + $content = file_get_contents('/proc/meminfo'); + preg_match('/MemTotal:\s+(\d+)/', $content, $total); + preg_match('/MemAvailable:\s+(\d+)/', $content, $available); + $t = (int)($total[1] ?? 0) * 1024; + $a = (int)($available[1] ?? 0) * 1024; + $u = $t - $a; + return [ + 'total' => $t, + 'used' => $u, + 'percent' => $t > 0 ? round(($u / $t) * 100, 1) : 0, + ]; + } +} diff --git a/video-converter-suite/storage/logs/.gitkeep b/video-converter-suite/storage/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/outputs/.gitkeep b/video-converter-suite/storage/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/temp/.gitkeep b/video-converter-suite/storage/temp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/thumbnails/.gitkeep b/video-converter-suite/storage/thumbnails/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/storage/uploads/.gitkeep b/video-converter-suite/storage/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/video-converter-suite/templates/dashboard.php b/video-converter-suite/templates/dashboard.php new file mode 100644 index 0000000..8b50dbc --- /dev/null +++ b/video-converter-suite/templates/dashboard.php @@ -0,0 +1,447 @@ + + + + + + <?= $config['app_name'] ?> - Control Panel + + + + + + + +
+ +
+
+ + SYSTEM ONLINE +
+
+ JOBS: + 0 +
+
00:00:00
+
+
+ + + + + +
+ + +
+ +
+
+
+
SYSTEM MONITOR
+ +
+
+
+
+
CPU Load
+
0.0
+
Load Avg
+
+
+
+
Memory
+
0
+
% Used
+
+
+
+
Disk
+
0
+
GB Free
+
+
+
+
Active Jobs
+
0
+
Running
+
+
+
+
+
+
+ + +
+
+
+
AKTIVE JOBS
+ +
+
+
+
+ Keine aktiven Jobs +
+
+
+
+
+
+
SYSTEM LOG
+ +
+
+
+
[INIT] Video Converter Suite gestartet
+
+
+
+
+
+ + + + + + + + + + + + +
+ + + + + +
+ + + +