# MODULES_EDIT_RULES.md

Zasady edycji modułów custom w Dolibarr.

---

## 0. Środowisko pracy

**Pracujemy przez SSH na serwerze Linux. Ścieżka robocza:**

```
/var/www/localhost/htdocs/dolibarr-dev/htdocs/
```

**✓ Używamy poleceń bash/Linux:**
```bash
grep, find, cat, ls, mkdir, cp, mv, sed, awk
```

**Terminal:** zawsze bash (`ssh`). Dostęp jako normalny user (`ciachoo`).  
Jeżeli operacja wymaga `root` — pytaj użytkownika przed wykonaniem.

---

## 1. Struktura repozytoriów Git

**Każdy moduł custom ma własne repozytorium Git:**

```
/var/www/localhost/htdocs/dolibarr-dev/htdocs/custom/
    ├── planning/         ← własne git repo
    ├── productionrules/  ← własne git repo
    ├── moduleA/          ← własne git repo
    └── moduleB/          ← własne git repo
```

**Katalog custom/ NIE jest jednym repozytorium** - każdy moduł zarządza swoim kodem niezależnie.

---

## 2. Git Commands - TYLKO z katalogów modułów

**✓ POPRAWNIE:**
```bash
cd /var/www/localhost/htdocs/dolibarr-dev/htdocs/custom/planning
git status
git add planning.php
git commit -m "Fix: tooltip enhancement"
git push
```

**✓ POPRAWNIE (z innego katalogu):**
```bash
git -C custom/planning status
git -C custom/planning add planning.php
git -C custom/planning commit -m "Fix: tooltip"
git -C custom/planning push
```

**✗ BŁĄD:**
```bash
cd /var/www/localhost/htdocs/dolibarr-dev/htdocs
git status                    # błąd: to nie jest repo git!

cd custom
git status                    # błąd: custom/ nie jest repo git!
```

---

## 3. Backup & Safety

- Każdy moduł ma własny `.gitignore` jeśli potrzebny
- Commit messages powinny zawierać context modułu
- Przed większymi zmianami: utwórz branch lub tag
- Remote repo URL skonfigurowane per moduł (zobacz `.git/config`)

---

## 4. Współpraca z CORE_EDIT_RULES.md

- **Pliki core/** → zobacz `custom/CORE_EDIT_RULES.md` (bez git commits)
- **Pliki custom/MODULE/** → ten plik (git commits dozwolone)

Nie commituj do git plików spoza swojego modułu.

---

## 5. Przykładowe workflow

```bash
# Pracujesz w planning module
cd /var/www/localhost/htdocs/dolibarr-dev/htdocs/custom/planning

# Sprawdź zmiany
git status
git diff

# Commit changes
git add .
git commit -m "Enhancement: add tooltip fields"

# Push to remote
git push origin master
```

---

## 6. Database Queries - TYLKO przez phpMyAdmin

**ZASADA: AI NIE uruchamia bezpośrednio mysql commands w terminalu.**

**Nazwa bazy danych: `Neos-dev`**

**Workflow:**
1. AI wkleja czytelne SQL query w odpowiedzi
2. Użytkownik kopiuje query do phpMyAdmin
3. Użytkownik wkleja rezultat (screenshot lub text)
4. AI analizuje wynik i kontynuuje

**✓ POPRAWNIE:**
```
Aby sprawdzić strukturę tabeli, wykonaj w phpMyAdmin:

DESCRIBE llx_mrp_mo_extrafields;

Lub:

SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'Neos-dev'
  AND TABLE_NAME = 'llx_mrp_mo_extrafields';
```

**✗ BŁĄD:**
```bash
mysql -u root -p'password' Neos-dev -e "DESCRIBE llx_mrp_mo_extrafields;"
# NIE uruchamiaj bezpośrednio!
```

**Dlaczego:**
- Bezpieczeństwo: nie eksponujemy credentials w terminal history
- Kontrola: użytkownik widzi i zatwierdza każde query
- Elastyczność: phpMyAdmin ma lepsze formatowanie wyników

---

## 7. Grupa modułów — zawsze Development

**ZASADA: Każdy nowy moduł custom musi być przypisany do grupy `Development`.**

W pliku `core/modules/modNazwaModulu.class.php` ustaw:

```php
$this->family = 'Development';
```

**Nigdy nie używaj** domyślnych grup Dolibarr (`products`, `commerce`, `tools`, `document`, itp.) dla modułów custom.

---

## 8. Language — all module content in English

**RULE: All documentation, UI strings, comments, and descriptions in custom modules must be written in English.**

This applies to:
- PHP files: inline comments, UI labels, button text, error messages, placeholders
- Markdown files: `README.md`, `ChangeLog.md`, `docs/*.md`, plan files
- SQL files: column `COMMENT` strings
- Git commit messages
- `$this->description` in `modXxx.class.php`

**✓ Correct:**
```php
print '<th>Reported At</th>';
setEventMessages('Scrap booked to system', null, 'mesgs');
```
```markdown
# ChangeLog
## v1.2.0 — Added Scrap Confirmation tab
```

**✗ Wrong:**
```php
print '<th>Data zgłoszenia</th>';
setEventMessages('Wpis zaksięgowany', null, 'mesgs');
```
```markdown
# ChangeLog
## v1.2.0 — Dodano zakładkę Scrap Confirmation
```

**Exception:** `langs/` translation files (`.lang`) may contain target-language strings by design.

---

---

## 9. Custom Menu & Submenu under MRP (file-based top menus)

**Context:** Dolibarr top menus like MRP, Accounting, etc. are hardcoded in `eldy.lib.php` and do NOT exist in `llx_menu`. String-based `fk_menu` like `'fk_mainmenu=mrp'` always resolves to `-1` in the DB.

**How `menuLeftCharger()` processes `fk_menu=-1` items:**
- `fk_leftmenu = NULL` → rendered as **bold level-0 section header**
- `fk_leftmenu = 'parent_leftmenu'` → rendered as **non-bold indented child** of the matching header

**DO NOT** set `fk_menu` to an actual rowid pointing to the header — Dolibarr runs those through `recur()` which requires a `type=top` entry in `llx_menu`. MRP has none, so children are never rendered.

### In `modXxx.class.php` constructor — `$this->menu`:

```php
$this->menu = array();

// Section header (bold) — position must be lower than children
$this->menu[] = array(
    'fk_menu'  => 'fk_mainmenu=mrp',   // resolves to -1 for MRP
    'type'     => 'left',
    'titre'    => 'My Section',
    'mainmenu' => 'mrp',
    'leftmenu' => 'my_section',         // unique identifier for this header
    'url'      => '/custom/mymod/list.php?leftmenu=my_section',
    'langs'    => 'mymod@mymod',
    'position' => 90,                   // LOWER than children
    'enabled'  => 'isModEnabled("mymod")',
    'perms'    => '$user->rights->mymod->read',
    'target'   => '',
    'user'     => 2,
);

// Sub-item — fk_menu same string as header, position > header
$this->menu[] = array(
    'fk_menu'  => 'fk_mainmenu=mrp',   // same as header
    'type'     => 'left',
    'titre'    => 'My List',
    'mainmenu' => 'mrp',
    'leftmenu' => 'my_section_list',
    'url'      => '/custom/mymod/list.php',
    'langs'    => 'mymod@mymod',
    'position' => 91,                   // HIGHER than header
    'enabled'  => 'isModEnabled("mymod")',
    'perms'    => '$user->rights->mymod->read',
    'target'   => '',
    'user'     => 2,
);
```

### After `_init()` — patch `llx_menu` with `_fixMenuParents()`:

`_init()` inserts all items with `fk_leftmenu=NULL`. You must patch children afterwards:

```php
public function init($options = '')
{
    $result = $this->_load_tables('/custom/mymod/sql/');
    $res    = $this->_init(array(), $options);
    if ($res && $result >= 0) {
        $this->_fixMenuParents();
        return 1;
    }
    return -1;
}

private function _fixMenuParents()
{
    $entity = (int)($GLOBALS['conf']->entity ?? 1);

    // Header stays: fk_menu=-1, fk_leftmenu=NULL
    $this->db->query(
        "UPDATE ".MAIN_DB_PREFIX."menu"
        ." SET fk_menu = -1, fk_leftmenu = NULL"
        ." WHERE module = 'mymod' AND leftmenu = 'my_section'"
        ." AND entity IN (0, ".$entity.")"
    );

    // Children: fk_menu=-1, fk_leftmenu = header's leftmenu value
    $this->db->query(
        "UPDATE ".MAIN_DB_PREFIX."menu"
        ." SET fk_menu = -1, fk_leftmenu = 'my_section'"
        ." WHERE module = 'mymod'"
        ." AND leftmenu IN ('my_section_list', 'my_section_other')"
        ." AND entity IN (0, ".$entity.")"
    );
}
```

**Rules summary:**
- All items: `fk_menu = 'fk_mainmenu=mrp'` in constructor (resolves to -1 anyway)
- Header position **<** child positions (e.g. 90, 91, 92)
- `_fixMenuParents()` called inside `init()` **after** `_init()`
- Deactivate + reactivate module after any menu structure change

**Note:** This pattern applies to all file-based top menus (MRP, Accounting, etc.). Modules with their **own** top menu entry in `llx_menu` (e.g. Planning) do not need `_fixMenuParents()`.

---

**Data utworzenia:** 2026-03-30  
**Ostatnia aktualizacja:** 2026-04-08

---

## 10. JavaScript and CSS — always in separate files

**RULE: Never embed JavaScript logic inside PHP strings. Never inline non-trivial CSS inside PHP.**

### File placement

| Type | Directory | Registration |
|------|-----------|--------------|
| JavaScript | `js/` | `module_parts['js']` |
| CSS | `css/` | `module_parts['css']` |

### In `modXxx.class.php` constructor:

```php
$this->module_parts = array(
    'menus' => 1,
    'css'   => array('/custom/mymod/css/mymod.css'),
    'js'    => array('/custom/mymod/js/mymod-feature.js'),
);
```

Dolibarr automatically includes registered CSS/JS on every page where the module is active.

### PHP helper functions — only a tiny call

PHP functions may output a `<script>` tag **only** to call an already-loaded external function, passing dynamic JSON data:

```php
// ✓ Correct — call external function with data
$out .= '<script>guillotineAutocomplete('
      . json_encode($iId) . ','
      . json_encode($hId) . ','
      . json_encode($lId) . ','
      . $json
      . ');</script>';
```

**✗ Wrong — never build JS logic in PHP string concatenation:**
```php
$out .= '<script>';
$out .= '(function(){';
$out .= 'var _d=' . $json . ';';
$out .= 'var _i=document.getElementById(...)';
// ... dozens of lines ...
$out .= '})();';
$out .= '</script>';
```

### Why

- PHP string concatenation corrupts JS syntax silently (quote escaping, variable interpolation)
- External files are cacheable, linted by editors, and diff-friendly in git
- Inline JS is impossible to test or reuse independently

### JSON data encoding

Always use all four hex-encoding flags when passing data from PHP to JS:

```php
$json = json_encode($data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE);
```

### PHP lint before every commit

```bash
php -l /var/www/localhost/htdocs/dolibarr-dev/htdocs/custom/mymod/lib/mymod.lib.php
```

---

**Data utworzenia:** 2026-03-30  
**Ostatnia aktualizacja:** 2026-05-14

---

## 11. CSS and JS version strings — use `filemtime()` auto cache-bust

**RULE: Never hardcode `?v=YYYYMMDD` version strings. Use `filemtime()` so the version updates automatically whenever the file changes.**

### Helper function — add to each module's shared helpers file

```php
// lib/<module>_helpers.php (or equivalent shared lib)

/**
 * Return a CSS/JS path with ?v= set to the file's mtime (auto cache-bust).
 * Usage: pl_asset_v('/custom/mymod/css/mymod.css')
 */
function pl_asset_v($relPath)
{
    $abs = DOL_DOCUMENT_ROOT . $relPath;
    $ts  = file_exists($abs) ? filemtime($abs) : 0;
    return $relPath . '?v=' . $ts;
}
```

Name the function consistently per module: `pl_asset_v` (planning), `<mod>_asset_v` (other modules).

### Usage in PHP pages

```php
// ✓ Correct — version auto-updates on file save
$morecss = array(pl_asset_v('/custom/mymod/css/mymod.css'));
$morejs  = array(pl_asset_v('/custom/mymod/js/mymod.js'));

// ✗ Wrong — manual string, easy to forget to bump
$morecss = array('/custom/mymod/css/mymod.css?v=20260412a');
```

### When the helper is not available (e.g. lib files that load before helpers)

Use `filemtime()` inline:

```php
$v = filemtime(DOL_DOCUMENT_ROOT . '/custom/mymod/css/mymod.css') ?: 0;
print '<link rel="stylesheet" href="' . DOL_URL_ROOT . '/custom/mymod/css/mymod.css?v=' . $v . '">';
```

### Why

Without a version string, browsers serve the cached old file. With `filemtime()`:
- No manual bumping — changing the file is sufficient
- No risk of forgetting to update one of several pages that load the same file
- Works correctly in production and development without any build step

---

## 12. Business Logic — SF→FG Trimming Flow (ScanStation)

### Overview

Parts are made in two stages:
1. **Forming** — SF (Semi-Finished) blank is pressed/formed at a forming workstation. One confirm = one SF piece.
2. **Trimming** — SF blank is cut at a laser or CNC workstation, yielding one FG (Finished Good) piece.

### Key rule: 1 SF blank = 1 FG piece

**Always.** Ratio is `1:1` in optracking, regardless of what BOM `mp.qty` says.

**Why BOM qty ≠ ratio:**  
BOM lines like `SF2371-00 qty=0.5` are material **cost allocation** entries (half a blank charged to this MO, half to the sibling MO). They do **not** describe how many FG parts come out of one blank.

Example: `SF2371-00` feeds two sibling MOs — `FG2378-01` (LH) and `FG2379-01` (RH). Each MO has BOM qty `0.5`, meaning each MO charges half the material cost of one blank. But cutting one blank still produces exactly `1 × FG2378-01` AND `1 × FG2379-01` — two separate MOs, each confirming 1 piece.

**DO NOT** compute ratio as `mo.qty / mp.qty` — for a 20-piece MO with BOM SF qty=10, that gives `2`, which is wrong.

### isTrimming detection

A workstation is a trimming station when **both** conditions are true:
- The scanned product is **not** an SF product (i.e. `is_sf = false`)
- The workstation type is `laser` or `cnc`

```js
// JS
var isTrimming = !is_sf && (ws_type === 'laser' || ws_type === 'cnc');
```

```php
// PHP (lookup action)
$isTrimming = !$isSf && ($wsType === 'laser' || $wsType === 'cnc');
```

When `isTrimming` is true:
- Qty row is hidden; Available row is shown
- User selects SF pills (which SF blanks they just cut)
- CONFIRM posts `sf_jt_rowids[]` array instead of a qty number

### SF availability

Available SF pieces = confirmed SF pieces for this MO's SF product that are:
- Not scrapped
- Not already consumed by a prior FG confirm for **this** FG MO (checked via `fk_source_jt`)
- Not reversed (reverse_confirm / reverse rows in `llx_scanstation_job_tracking`)

The check for "already consumed" excludes FG confirms that were themselves later reversed — so reversing a FG confirm releases the SF pieces back into the Available pool.

### Confirm (trimming path)

```
qty_delta (optracking) = count(selected_sf_pieces) × 1
```

One `optracking` row is inserted for the total FG qty (piece_num = first SF piece num).  
One `job_tracking` row per SF piece — `fk_source_jt` links back to the original SF confirm row.

### Reverse (trimming path)

When reversing a FG piece that was confirmed via trimming (`fk_source_jt IS NOT NULL`), the undo delta is `-1` per piece — same as non-trimming, because the confirm also wrote `+1` per piece.

**DO NOT** use sfRatio in reverse — ratio is always 1 and the confirm already stored `qty_delta = count × 1`.

### Consumed check pattern (SQL)

Used in three places: `sf_available` in lookup, `get_sf_pieces`, and `rAlready` confirm guard.

```sql
AND NOT EXISTS (
    SELECT 1 FROM llx_scanstation_job_tracking rev
    WHERE rev.fk_mo = c.fk_mo AND rev.piece_num = c.piece_num
    AND rev.scan_type IN ('reverse_confirm', 'reverse')
    AND rev.rowid > c.rowid AND rev.entity = c.entity
)
```

