Compare commits
6 Commits
c881dd7dde
...
c736dbe873
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c736dbe873 | ||
|
|
2dcef87229 | ||
|
|
d512f8b00d | ||
|
|
d1220fdd3f | ||
|
|
f451c3df63 | ||
|
|
fedb1c88a8 |
15
README.md
15
README.md
@@ -52,6 +52,21 @@ The scheduled endpoint is API-key protected and returns a summary:
|
||||
}
|
||||
```
|
||||
|
||||
### Cutting Workbook Import
|
||||
|
||||
Manual cutting/replenishment workbooks can be normalized into JSON before they
|
||||
are persisted or shown in the app:
|
||||
|
||||
```bash
|
||||
python3 backend/scripts/normalize_cut_workbook.py "/path/to/NECESSIDADE DE CORTE.xlsx" --pretty -o /tmp/necessidade_corte_normalized.json
|
||||
```
|
||||
|
||||
The output includes purchase need rows, production orders, finished stock,
|
||||
stock plus OP availability, real purchase need, outside items, and cut-plan
|
||||
sections by family (`BLCS`, `BLMC`, `BLOS`, `BLPM`). The importer reads cached
|
||||
workbook values and reports broken formula references instead of making the app
|
||||
depend on Excel formulas at runtime.
|
||||
|
||||
## Local Development
|
||||
|
||||
Start PostgreSQL:
|
||||
|
||||
@@ -122,6 +122,24 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cutting_family_rules (
|
||||
family_key VARCHAR(20) PRIMARY KEY,
|
||||
units_per_roll NUMERIC(14, 4),
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cutting_product_overrides (
|
||||
product_id VARCHAR(100) PRIMARY KEY,
|
||||
family_key VARCHAR(20),
|
||||
color VARCHAR(100),
|
||||
size VARCHAR(40),
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -130,6 +148,18 @@ const initDB = async () => {
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE cutting_family_rules
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE cutting_product_overrides
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -197,6 +227,7 @@ const initDB = async () => {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome
|
||||
|
||||
23
backend/routes/cuttingSettingsRoutes.js
Normal file
23
backend/routes/cuttingSettingsRoutes.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { listCuttingSettings, saveCuttingSettings } = require('../services/cuttingSettingsService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/cutting-settings', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listCuttingSettings());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/cutting-settings', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await saveCuttingSettings(req.body || {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
519
backend/scripts/normalize_cut_workbook.py
Normal file
519
backend/scripts/normalize_cut_workbook.py
Normal file
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize Nexstar cutting/replenishment workbooks into JSON.
|
||||
|
||||
The workbook is used as an operational planning model. This importer treats it
|
||||
as an input source and emits auditable tables that the app can later persist or
|
||||
render, without depending on the workbook formulas at runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
NS = {
|
||||
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
|
||||
"rel": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"pkgrel": "http://schemas.openxmlformats.org/package/2006/relationships",
|
||||
}
|
||||
|
||||
DATE_NUM_FMT_IDS = {
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
22,
|
||||
27,
|
||||
30,
|
||||
36,
|
||||
45,
|
||||
46,
|
||||
47,
|
||||
50,
|
||||
57,
|
||||
}
|
||||
|
||||
CUT_SHEETS = {"BLCS", "BLMC", "BLOS", "BLPM"}
|
||||
COLOR_WORDS = {
|
||||
"amarelo",
|
||||
"azul",
|
||||
"azul pr.",
|
||||
"azul prussia",
|
||||
"bege",
|
||||
"bordo",
|
||||
"bordô",
|
||||
"branco",
|
||||
"cafe",
|
||||
"café",
|
||||
"cinza",
|
||||
"cor??",
|
||||
"grafite",
|
||||
"marinho",
|
||||
"marrom",
|
||||
"perola",
|
||||
"pérola",
|
||||
"preto",
|
||||
"rosa",
|
||||
"rox0",
|
||||
"roxo",
|
||||
"royal",
|
||||
"verde b.",
|
||||
"verde bandeira",
|
||||
"verde militar",
|
||||
"vermelho",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Cell:
|
||||
value: Any
|
||||
formula: Optional[str] = None
|
||||
has_broken_reference: bool = False
|
||||
|
||||
|
||||
def local_path(base: str, target: str) -> str:
|
||||
if target.startswith("/"):
|
||||
return target.lstrip("/")
|
||||
return posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
|
||||
|
||||
|
||||
def xml_root(archive: zipfile.ZipFile, path: str) -> ET.Element:
|
||||
with archive.open(path) as handle:
|
||||
return ET.parse(handle).getroot()
|
||||
|
||||
|
||||
def parse_relationships(archive: zipfile.ZipFile, path: str) -> Dict[str, str]:
|
||||
if path not in archive.namelist():
|
||||
return {}
|
||||
|
||||
root = xml_root(archive, path)
|
||||
relationships: Dict[str, str] = {}
|
||||
for rel in root:
|
||||
rel_id = rel.attrib.get("Id")
|
||||
target = rel.attrib.get("Target")
|
||||
if rel_id and target:
|
||||
relationships[rel_id] = target
|
||||
return relationships
|
||||
|
||||
|
||||
def load_shared_strings(archive: zipfile.ZipFile) -> List[str]:
|
||||
if "xl/sharedStrings.xml" not in archive.namelist():
|
||||
return []
|
||||
|
||||
root = xml_root(archive, "xl/sharedStrings.xml")
|
||||
strings: List[str] = []
|
||||
for item in root.findall("main:si", NS):
|
||||
parts = [node.text or "" for node in item.findall(".//main:t", NS)]
|
||||
strings.append("".join(parts))
|
||||
return strings
|
||||
|
||||
|
||||
def load_date_style_ids(archive: zipfile.ZipFile) -> set[int]:
|
||||
if "xl/styles.xml" not in archive.namelist():
|
||||
return set()
|
||||
|
||||
root = xml_root(archive, "xl/styles.xml")
|
||||
date_num_fmt_ids = set(DATE_NUM_FMT_IDS)
|
||||
for fmt in root.findall("main:numFmts/main:numFmt", NS):
|
||||
num_fmt_id = fmt.attrib.get("numFmtId")
|
||||
format_code = fmt.attrib.get("formatCode", "").lower()
|
||||
if not num_fmt_id:
|
||||
continue
|
||||
if re.search(r"(^|[^\\[])[dmy]([^\\]]|$)", format_code):
|
||||
date_num_fmt_ids.add(int(num_fmt_id))
|
||||
|
||||
style_ids: set[int] = set()
|
||||
for index, cell_format in enumerate(root.findall("main:cellXfs/main:xf", NS)):
|
||||
num_fmt_id = int(cell_format.attrib.get("numFmtId", "0"))
|
||||
if num_fmt_id in date_num_fmt_ids:
|
||||
style_ids.add(index)
|
||||
return style_ids
|
||||
|
||||
|
||||
def excel_date(value: float) -> str:
|
||||
# Excel's 1900 date system includes the leap-year bug; this origin mirrors it
|
||||
# for normal post-1900 workbook dates.
|
||||
date = datetime(1899, 12, 30) + timedelta(days=value)
|
||||
return date.date().isoformat()
|
||||
|
||||
|
||||
def parse_scalar(value: Optional[str], style_id: Optional[int], cell_type: Optional[str], shared_strings: List[str], date_style_ids: set[int]) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if cell_type == "s":
|
||||
try:
|
||||
return shared_strings[int(value)]
|
||||
except (ValueError, IndexError):
|
||||
return value
|
||||
|
||||
if cell_type == "b":
|
||||
return value == "1"
|
||||
|
||||
if cell_type in {"str", "inlineStr"}:
|
||||
return value
|
||||
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
if style_id in date_style_ids:
|
||||
return excel_date(number)
|
||||
|
||||
if number.is_integer():
|
||||
return int(number)
|
||||
return number
|
||||
|
||||
|
||||
def cell_column(cell_ref: str) -> int:
|
||||
letters = re.sub(r"[^A-Z]", "", cell_ref.upper())
|
||||
column = 0
|
||||
for letter in letters:
|
||||
column = column * 26 + (ord(letter) - ord("A") + 1)
|
||||
return max(1, column)
|
||||
|
||||
|
||||
def parse_inline_string(cell_node: ET.Element) -> Optional[str]:
|
||||
inline = cell_node.find("main:is", NS)
|
||||
if inline is None:
|
||||
return None
|
||||
return "".join(node.text or "" for node in inline.findall(".//main:t", NS))
|
||||
|
||||
|
||||
def parse_sheet_rows(
|
||||
archive: zipfile.ZipFile,
|
||||
sheet_path: str,
|
||||
shared_strings: List[str],
|
||||
date_style_ids: set[int],
|
||||
) -> List[List[Cell]]:
|
||||
root = xml_root(archive, sheet_path)
|
||||
rows: List[List[Cell]] = []
|
||||
|
||||
for row in root.findall(".//main:sheetData/main:row", NS):
|
||||
parsed: List[Cell] = []
|
||||
last_column = 0
|
||||
for cell_node in row.findall("main:c", NS):
|
||||
ref = cell_node.attrib.get("r", "")
|
||||
column = cell_column(ref) if ref else last_column + 1
|
||||
while len(parsed) < column - 1:
|
||||
parsed.append(Cell(None))
|
||||
|
||||
cell_type = cell_node.attrib.get("t")
|
||||
style_id = int(cell_node.attrib.get("s", "-1"))
|
||||
formula_node = cell_node.find("main:f", NS)
|
||||
value_node = cell_node.find("main:v", NS)
|
||||
formula = formula_node.text if formula_node is not None else None
|
||||
|
||||
if cell_type == "inlineStr":
|
||||
value = parse_inline_string(cell_node)
|
||||
else:
|
||||
value = parse_scalar(
|
||||
value_node.text if value_node is not None else None,
|
||||
style_id,
|
||||
cell_type,
|
||||
shared_strings,
|
||||
date_style_ids,
|
||||
)
|
||||
|
||||
parsed.append(Cell(value=value, formula=formula, has_broken_reference="#REF!" in (formula or "")))
|
||||
last_column = column
|
||||
|
||||
while parsed and parsed[-1].value is None and not parsed[-1].formula:
|
||||
parsed.pop()
|
||||
if parsed:
|
||||
rows.append(parsed)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def workbook_rows(path: str) -> Dict[str, List[List[Cell]]]:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
workbook_path = "xl/workbook.xml"
|
||||
workbook = xml_root(archive, workbook_path)
|
||||
rels = parse_relationships(archive, "xl/_rels/workbook.xml.rels")
|
||||
shared_strings = load_shared_strings(archive)
|
||||
date_style_ids = load_date_style_ids(archive)
|
||||
|
||||
rows_by_sheet: Dict[str, List[List[Cell]]] = {}
|
||||
for sheet in workbook.findall("main:sheets/main:sheet", NS):
|
||||
name = sheet.attrib["name"]
|
||||
rel_id = sheet.attrib.get(f"{{{NS['rel']}}}id")
|
||||
target = rels.get(rel_id or "")
|
||||
if not target:
|
||||
continue
|
||||
sheet_path = local_path(workbook_path, target)
|
||||
rows_by_sheet[name] = parse_sheet_rows(archive, sheet_path, shared_strings, date_style_ids)
|
||||
|
||||
return rows_by_sheet
|
||||
|
||||
|
||||
def clean_text(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def number_or_zero(value: Any) -> float:
|
||||
if value in (None, ""):
|
||||
return 0
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
normalized = str(value).strip().replace(".", "").replace(",", ".")
|
||||
try:
|
||||
return float(normalized)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def int_if_whole(value: float) -> int | float:
|
||||
return int(value) if float(value).is_integer() else value
|
||||
|
||||
|
||||
def row_values(row: List[Cell]) -> List[Any]:
|
||||
return [cell.value for cell in row]
|
||||
|
||||
|
||||
def get(row: List[Any], index: int) -> Any:
|
||||
return row[index] if index < len(row) else None
|
||||
|
||||
|
||||
def non_empty_rows(rows: Iterable[List[Cell]]) -> Iterable[List[Any]]:
|
||||
for row in rows:
|
||||
values = row_values(row)
|
||||
if any(value not in (None, "") for value in values):
|
||||
yield values
|
||||
|
||||
|
||||
def normalize_purchase_need(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
|
||||
output: List[Dict[str, Any]] = []
|
||||
for values in list(non_empty_rows(rows))[1:]:
|
||||
product = clean_text(get(values, 0))
|
||||
sku = clean_text(get(values, 1))
|
||||
if not product:
|
||||
continue
|
||||
output.append(
|
||||
{
|
||||
"product": product,
|
||||
"sku": sku,
|
||||
"supplier": clean_text(get(values, 2)),
|
||||
"unit": clean_text(get(values, 3)),
|
||||
"averageCost": number_or_zero(get(values, 4)),
|
||||
"expectedIn": number_or_zero(get(values, 5)),
|
||||
"expectedOut": number_or_zero(get(values, 6)),
|
||||
"physicalStock": number_or_zero(get(values, 7)),
|
||||
"virtualStock": number_or_zero(get(values, 8)),
|
||||
"periodOut": number_or_zero(get(values, 9)),
|
||||
"monthlyAverage": number_or_zero(get(values, 10)),
|
||||
"coverageMonths": number_or_zero(get(values, 11)),
|
||||
"suggestedPurchase": number_or_zero(get(values, 12)),
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def normalize_production_orders(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
|
||||
output: List[Dict[str, Any]] = []
|
||||
for values in list(non_empty_rows(rows))[1:]:
|
||||
product = clean_text(get(values, 4))
|
||||
quantity = number_or_zero(get(values, 6))
|
||||
if not product and not quantity:
|
||||
continue
|
||||
output.append(
|
||||
{
|
||||
"number": clean_text(get(values, 0)),
|
||||
"orderReference": clean_text(get(values, 1)),
|
||||
"startDate": get(values, 2),
|
||||
"expectedDate": get(values, 3),
|
||||
"product": product,
|
||||
"status": clean_text(get(values, 5)),
|
||||
"quantity": int_if_whole(quantity),
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def normalize_product_mapping(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
|
||||
output: List[Dict[str, Any]] = []
|
||||
for values in list(non_empty_rows(rows))[1:]:
|
||||
product = clean_text(get(values, 4))
|
||||
if not product:
|
||||
continue
|
||||
output.append(
|
||||
{
|
||||
"product": product,
|
||||
"purchaseNeedProduct": clean_text(get(values, 0)),
|
||||
"physicalStock": number_or_zero(get(values, 1)),
|
||||
"productionOrderProduct": clean_text(get(values, 2)),
|
||||
"productionOrderQuantity": number_or_zero(get(values, 3)),
|
||||
"totalProductionOrder": number_or_zero(get(values, 5)),
|
||||
"stockPlusProductionOrder": number_or_zero(get(values, 6)),
|
||||
"finishedStock": number_or_zero(get(values, 7)),
|
||||
}
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def normalize_two_column_quantity(rows: List[List[Cell]], quantity_name: str) -> List[Dict[str, Any]]:
|
||||
output: List[Dict[str, Any]] = []
|
||||
for values in non_empty_rows(rows):
|
||||
product = clean_text(get(values, 0))
|
||||
if not product:
|
||||
continue
|
||||
output.append({"product": product, quantity_name: number_or_zero(get(values, 1))})
|
||||
return output
|
||||
|
||||
|
||||
def normalize_matrix_sheet(rows: List[List[Cell]]) -> Dict[str, Any]:
|
||||
values = list(non_empty_rows(rows))
|
||||
title = clean_text(get(values[0], 0)) if values else ""
|
||||
total = number_or_zero(get(values[0], 2) if len(values[0]) > 2 else get(values[0], 1)) if values else 0
|
||||
return {
|
||||
"title": title,
|
||||
"total": int_if_whole(total),
|
||||
"rows": [row[:40] for row in values[:120]],
|
||||
}
|
||||
|
||||
|
||||
def is_color_label(value: str) -> bool:
|
||||
normalized = value.strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return normalized in COLOR_WORDS
|
||||
|
||||
|
||||
def extract_color_labels(rows: List[List[Any]], section_index: int) -> List[str]:
|
||||
candidates: List[Tuple[int, List[str]]] = []
|
||||
for nearby in rows[section_index + 1 : section_index + 8]:
|
||||
labels = [clean_text(value) for value in nearby]
|
||||
colors = [label for label in labels if is_color_label(label)]
|
||||
if len(colors) >= 2:
|
||||
candidates.append((len(colors), colors))
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
return max(candidates, key=lambda item: item[0])[1][:24]
|
||||
|
||||
|
||||
def first_number(values: List[Any]) -> float:
|
||||
for value in values:
|
||||
number = number_or_zero(value)
|
||||
if number:
|
||||
return number
|
||||
return 0.0
|
||||
|
||||
|
||||
def normalize_cut_sheet(name: str, rows: List[List[Cell]]) -> Dict[str, Any]:
|
||||
values = list(non_empty_rows(rows))
|
||||
sections: List[Dict[str, Any]] = []
|
||||
broken_references = sum(1 for row in rows for cell in row if cell.has_broken_reference)
|
||||
|
||||
for index, row in enumerate(values):
|
||||
labels = [clean_text(value) for value in row]
|
||||
if not any(label.lower() == "total de rolos" for label in labels):
|
||||
continue
|
||||
|
||||
title = next((label for label in labels if "corte" in label.lower()), "")
|
||||
total_rolls = first_number(row)
|
||||
if not total_rolls and index + 1 < len(values):
|
||||
next_row = values[index + 1]
|
||||
next_labels = [clean_text(value) for value in next_row]
|
||||
if any(label.lower() == "total de rolos" for label in next_labels):
|
||||
total_rolls = first_number(next_row)
|
||||
|
||||
total_need = 0.0
|
||||
for position, label in enumerate(labels):
|
||||
if label.lower().startswith("total necc"):
|
||||
total_need = number_or_zero(get(row, position + 1))
|
||||
break
|
||||
|
||||
color_row = extract_color_labels(values, index)
|
||||
|
||||
if title:
|
||||
sections.append(
|
||||
{
|
||||
"family": name,
|
||||
"title": title,
|
||||
"totalRolls": int_if_whole(total_rolls),
|
||||
"totalNeed": int_if_whole(total_need),
|
||||
"colors": color_row[:24],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"sheet": name,
|
||||
"brokenFormulaReferences": broken_references,
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def normalize_workbook(path: str) -> Dict[str, Any]:
|
||||
sheets = workbook_rows(path)
|
||||
normalized = {
|
||||
"source": path,
|
||||
"generatedAt": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
|
||||
"summary": {
|
||||
"sheetCount": len(sheets),
|
||||
"sheets": list(sheets.keys()),
|
||||
},
|
||||
"purchaseNeed": normalize_purchase_need(sheets.get("NECESSIDADE DE COMPRA (colar)", [])),
|
||||
"productMappings": normalize_product_mapping(sheets.get("INSERIR NOVOS PRODUTOS", [])),
|
||||
"productionOrders": normalize_production_orders(sheets.get("ORDEM DE PRODUÇÃO (colar)", [])),
|
||||
"realPurchaseNeed": normalize_two_column_quantity(sheets.get("NECESSIDADE REAL DE COMPRA", []), "realNeed"),
|
||||
"outsideItems": normalize_two_column_quantity(sheets.get("ITENS POR FORA", []), "quantity"),
|
||||
"finishedStockMatrix": normalize_matrix_sheet(sheets.get("ESTOQUE PA", [])),
|
||||
"stockPlusProductionOrderMatrix": normalize_matrix_sheet(sheets.get("ESTOQUE PA+ ORDEM DE PRODUÇÃO", [])),
|
||||
"cutPlans": [
|
||||
normalize_cut_sheet(sheet_name, rows)
|
||||
for sheet_name, rows in sheets.items()
|
||||
if sheet_name in CUT_SHEETS
|
||||
],
|
||||
}
|
||||
|
||||
normalized["summary"].update(
|
||||
{
|
||||
"purchaseNeedRows": len(normalized["purchaseNeed"]),
|
||||
"productMappingRows": len(normalized["productMappings"]),
|
||||
"productionOrderRows": len(normalized["productionOrders"]),
|
||||
"realPurchaseNeedRows": len(normalized["realPurchaseNeed"]),
|
||||
"outsideItemRows": len(normalized["outsideItems"]),
|
||||
"cutPlanSections": sum(len(plan["sections"]) for plan in normalized["cutPlans"]),
|
||||
"brokenFormulaReferences": sum(plan["brokenFormulaReferences"] for plan in normalized["cutPlans"]),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Normalize NECESSIDADE DE CORTE workbooks into JSON.")
|
||||
parser.add_argument("workbook", help="Path to the .xlsx workbook")
|
||||
parser.add_argument("-o", "--output", help="Optional output JSON file. Defaults to stdout.")
|
||||
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
payload = normalize_workbook(args.workbook)
|
||||
except Exception as error: # noqa: BLE001 - CLI should report any parse failure cleanly.
|
||||
print(f"Failed to normalize workbook: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.write("\n")
|
||||
else:
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -9,6 +9,7 @@ const internalRoutes = require('./routes/internalRoutes');
|
||||
const analyticsRoutes = require('./routes/analyticsRoutes');
|
||||
const userRoutes = require('./routes/userRoutes');
|
||||
const productionOrderRoutes = require('./routes/productionOrderRoutes');
|
||||
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
||||
|
||||
const createApp = () => {
|
||||
const app = express();
|
||||
@@ -21,6 +22,7 @@ const createApp = () => {
|
||||
app.use('/api', stockRoutes);
|
||||
app.use('/api', campaignRoutes);
|
||||
app.use('/api', productionOrderRoutes);
|
||||
app.use('/api', cuttingSettingsRoutes);
|
||||
app.use('/api', analyticsRoutes);
|
||||
app.use('/api', userRoutes);
|
||||
app.use('/api/internal', internalRoutes);
|
||||
|
||||
128
backend/services/cuttingSettingsService.js
Normal file
128
backend/services/cuttingSettingsService.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
|
||||
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
|
||||
|
||||
const normalizeFamilyKey = (value) => {
|
||||
const familyKey = String(value || '').trim().toUpperCase();
|
||||
return FAMILY_KEY_SET.has(familyKey) ? familyKey : '';
|
||||
};
|
||||
|
||||
const normalizeNumber = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : null;
|
||||
};
|
||||
|
||||
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const normalizeFamilyYields = (familyYields = {}) => {
|
||||
return FAMILY_KEYS.reduce((normalized, familyKey) => {
|
||||
const unitsPerRoll = normalizeNumber(familyYields[familyKey]);
|
||||
if (unitsPerRoll) normalized[familyKey] = unitsPerRoll;
|
||||
return normalized;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const normalizeProductOverrides = (productOverrides = {}) => {
|
||||
return Object.entries(productOverrides).reduce((normalized, [productId, override]) => {
|
||||
const normalizedProductId = normalizeText(productId);
|
||||
if (!normalizedProductId || !override || typeof override !== 'object') return normalized;
|
||||
|
||||
const familyKey = normalizeFamilyKey(override.familyKey);
|
||||
const color = normalizeText(override.color);
|
||||
const size = normalizeText(override.size).toUpperCase();
|
||||
|
||||
if (!familyKey && !color && !size) return normalized;
|
||||
|
||||
normalized[normalizedProductId] = {
|
||||
familyKey,
|
||||
color,
|
||||
size
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const listCuttingSettings = async () => {
|
||||
const [familyResult, overrideResult] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT family_key, units_per_roll
|
||||
FROM cutting_family_rules
|
||||
WHERE units_per_roll IS NOT NULL AND units_per_roll > 0
|
||||
ORDER BY family_key
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT product_id, family_key, color, size
|
||||
FROM cutting_product_overrides
|
||||
ORDER BY product_id
|
||||
`)
|
||||
]);
|
||||
|
||||
return {
|
||||
familyYields: familyResult.rows.reduce((settings, row) => {
|
||||
settings[row.family_key] = Number(row.units_per_roll);
|
||||
return settings;
|
||||
}, {}),
|
||||
productOverrides: overrideResult.rows.reduce((settings, row) => {
|
||||
settings[row.product_id] = {
|
||||
familyKey: row.family_key || '',
|
||||
color: row.color || '',
|
||||
size: row.size || ''
|
||||
};
|
||||
return settings;
|
||||
}, {})
|
||||
};
|
||||
};
|
||||
|
||||
const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} }) => {
|
||||
const normalizedFamilyYields = normalizeFamilyYields(familyYields);
|
||||
const normalizedProductOverrides = normalizeProductOverrides(productOverrides);
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query('DELETE FROM cutting_family_rules');
|
||||
for (const [familyKey, unitsPerRoll] of Object.entries(normalizedFamilyYields)) {
|
||||
await client.query(`
|
||||
INSERT INTO cutting_family_rules (family_key, units_per_roll, updated_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
`, [familyKey, unitsPerRoll]);
|
||||
}
|
||||
|
||||
await client.query('DELETE FROM cutting_product_overrides');
|
||||
for (const [productId, override] of Object.entries(normalizedProductOverrides)) {
|
||||
await client.query(`
|
||||
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, updated_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||
`, [
|
||||
productId,
|
||||
override.familyKey || null,
|
||||
override.color || null,
|
||||
override.size || null
|
||||
]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return {
|
||||
familyYields: normalizedFamilyYields,
|
||||
productOverrides: normalizedProductOverrides
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
FAMILY_KEYS,
|
||||
listCuttingSettings,
|
||||
normalizeFamilyKey,
|
||||
normalizeFamilyYields,
|
||||
normalizeProductOverrides,
|
||||
saveCuttingSettings
|
||||
};
|
||||
40
backend/test/cuttingSettingsService.test.js
Normal file
40
backend/test/cuttingSettingsService.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
normalizeFamilyKey,
|
||||
normalizeFamilyYields,
|
||||
normalizeProductOverrides
|
||||
} = require('../services/cuttingSettingsService');
|
||||
|
||||
test('normalizeFamilyKey accepts only known cut families', () => {
|
||||
assert.equal(normalizeFamilyKey('blcs'), 'BLCS');
|
||||
assert.equal(normalizeFamilyKey(' BLOS '), 'BLOS');
|
||||
assert.equal(normalizeFamilyKey('OUTROS'), '');
|
||||
assert.equal(normalizeFamilyKey('unknown'), '');
|
||||
});
|
||||
|
||||
test('normalizeFamilyYields keeps positive numeric yield rules', () => {
|
||||
assert.deepEqual(normalizeFamilyYields({
|
||||
BLCS: '50',
|
||||
BLOS: 0,
|
||||
BLMC: -1,
|
||||
BLPM: '12.5',
|
||||
OUTROS: 99
|
||||
}), {
|
||||
BLCS: 50,
|
||||
BLPM: 12.5
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeProductOverrides trims and removes empty overrides', () => {
|
||||
assert.deepEqual(normalizeProductOverrides({
|
||||
' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ' },
|
||||
'SKU-2': { familyKey: 'OUTROS', color: '', size: '' },
|
||||
'SKU-3': { familyKey: '', color: ' Branco ', size: '' },
|
||||
'SKU-4': null
|
||||
}), {
|
||||
'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M' },
|
||||
'SKU-3': { familyKey: '', color: 'Branco', size: '' }
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const Products = React.lazy(() => import('./pages/Products'));
|
||||
const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
|
||||
const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails'));
|
||||
const Replenishment = React.lazy(() => import('./pages/Replenishment'));
|
||||
const Cutting = React.lazy(() => import('./pages/Cutting'));
|
||||
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
|
||||
const Clients = React.lazy(() => import('./pages/Clients'));
|
||||
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
||||
@@ -50,6 +51,7 @@ function App() {
|
||||
<Route path="products/groups/:groupKey" element={<ProductGroupDetails />} />
|
||||
<Route path="products/:id" element={<ProductDetails />} />
|
||||
<Route path="replenishment" element={<Replenishment />} />
|
||||
<Route path="cutting" element={<Cutting />} />
|
||||
<Route path="stock" element={<Navigate to="/products" replace />} />
|
||||
<Route path="stock-alerts" element={<Navigate to="/products" replace />} />
|
||||
<Route path="production-orders" element={<ProductionOrders />} />
|
||||
|
||||
132
src/analytics/cutting.test.ts
Normal file
132
src/analytics/cutting.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildCutPlan, buildOpenProductionByProductId, classifyCutFamily } from './cutting.ts';
|
||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types.ts';
|
||||
|
||||
const range: DateRange = {
|
||||
start: new Date(2026, 6, 1),
|
||||
end: new Date(2026, 6, 7)
|
||||
};
|
||||
|
||||
const product = (overrides: Partial<ProductAnalyticsItem>): ProductAnalyticsItem => ({
|
||||
id: 'SKU-1',
|
||||
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - M',
|
||||
quantitySold: 70,
|
||||
revenue: 700,
|
||||
orderLineCount: 10,
|
||||
lastPrice: 10,
|
||||
stock: 20,
|
||||
firstSaleDate: '2026-07-01',
|
||||
lastSaleDate: '2026-07-07',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const productionOrder = (overrides: Partial<ProductionOrderItem>): ProductionOrderItem => ({
|
||||
id: 1,
|
||||
tinyId: '',
|
||||
number: 'OP-1',
|
||||
status: 'in_progress',
|
||||
statusLabel: 'Em andamento',
|
||||
orderReference: '',
|
||||
issueDate: '2026-07-01',
|
||||
expectedDate: '2026-07-15',
|
||||
productSku: '',
|
||||
productDescription: 'BASE LISA CAMISETA COR PRETO TAMANHO - M',
|
||||
quantity: 40,
|
||||
unit: 'UN',
|
||||
integrationStatus: '',
|
||||
markers: [],
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
test('classifyCutFamily maps known product bases to internal cut families', () => {
|
||||
assert.equal(classifyCutFamily('BASE LISA CAMISETA').key, 'BLCS');
|
||||
assert.equal(classifyCutFamily('BASE LISA CAMISETA OVER').key, 'BLOS');
|
||||
assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLPM');
|
||||
assert.equal(classifyCutFamily('BONÉ').key, 'OUTROS');
|
||||
});
|
||||
|
||||
test('buildCutPlan calculates projected cut need from sales pace and stock', () => {
|
||||
const plan = buildCutPlan([product({})], range, 14);
|
||||
const [row] = plan.needRows;
|
||||
|
||||
assert.equal(row.dailySales, 10);
|
||||
assert.equal(row.projectedDemand, 140);
|
||||
assert.equal(row.availableQuantity, 20);
|
||||
assert.equal(row.suggestedCutQuantity, 120);
|
||||
assert.equal(row.estimatedRolls, null);
|
||||
assert.deepEqual(row.issues, ['missing_yield_rule']);
|
||||
assert.equal(plan.summary.suggestedCutQuantity, 120);
|
||||
});
|
||||
|
||||
test('buildCutPlan subtracts open production quantity from suggested cut need', () => {
|
||||
const plan = buildCutPlan([product({ id: 'SKU-1' })], range, 14, { 'SKU-1': 100 });
|
||||
const [row] = plan.needRows;
|
||||
|
||||
assert.equal(row.availableQuantity, 120);
|
||||
assert.equal(row.suggestedCutQuantity, 20);
|
||||
});
|
||||
|
||||
test('buildCutPlan estimates rolls when a family yield is configured', () => {
|
||||
const plan = buildCutPlan([product({})], range, 14, {}, { familyYields: { BLCS: 50 } });
|
||||
const [row] = plan.needRows;
|
||||
|
||||
assert.equal(row.suggestedCutQuantity, 120);
|
||||
assert.equal(row.estimatedRolls, 3);
|
||||
assert.deepEqual(row.issues, []);
|
||||
assert.equal(plan.summary.estimatedRolls, 3);
|
||||
});
|
||||
|
||||
test('buildCutPlan applies per-product planning overrides', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
], range, 7, {}, {
|
||||
familyYields: { BLCS: 35 },
|
||||
productOverrides: {
|
||||
'SKU-2': { familyKey: 'BLCS', color: 'Preto', size: 'M' }
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(plan.needRows[0].family.key, 'BLCS');
|
||||
assert.equal(plan.needRows[0].color, 'Preto');
|
||||
assert.equal(plan.needRows[0].size, 'M');
|
||||
assert.equal(plan.needRows[0].estimatedRolls, 2);
|
||||
assert.deepEqual(plan.needRows[0].issues, []);
|
||||
});
|
||||
|
||||
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
|
||||
const products = [
|
||||
product({ id: 'SKU-1' }),
|
||||
product({ id: 'SKU-2', name: 'BASE LISA CAMISETA COR BRANCO TAMANHO - G' })
|
||||
];
|
||||
const totals = buildOpenProductionByProductId(products, [
|
||||
productionOrder({ productSku: 'SKU-1', productDescription: '', quantity: 10 }),
|
||||
productionOrder({ productSku: '', productDescription: 'Base Lisa Camiseta Cor Branco Tamanho - G', quantity: 20 }),
|
||||
productionOrder({ productSku: 'SKU-1', status: 'finished', quantity: 999 })
|
||||
]);
|
||||
|
||||
assert.deepEqual(totals, { 'SKU-1': 10, 'SKU-2': 20 });
|
||||
});
|
||||
|
||||
test('buildCutPlan marks products that cannot be planned cleanly for cutting', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
], range, 7);
|
||||
|
||||
assert.equal(plan.needRows[0].family.key, 'OUTROS');
|
||||
assert.deepEqual(plan.needRows[0].issues, ['missing_family_rule', 'missing_color', 'missing_size']);
|
||||
assert.equal(plan.summary.rowsWithIssues, 1);
|
||||
});
|
||||
298
src/analytics/cutting.ts
Normal file
298
src/analytics/cutting.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
||||
|
||||
export type { CutFamilyKey, CutProductOverride };
|
||||
|
||||
export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size' | 'missing_yield_rule';
|
||||
|
||||
export interface CutFamilyRule {
|
||||
key: CutFamilyKey;
|
||||
label: string;
|
||||
materialLabel: string;
|
||||
keywords: string[];
|
||||
unitsPerRoll: number | null;
|
||||
}
|
||||
|
||||
export interface CutPlanOptions {
|
||||
familyYields?: Partial<Record<CutFamilyKey, number>>;
|
||||
productOverrides?: Record<string, CutProductOverride>;
|
||||
}
|
||||
|
||||
export interface CutPlanSkuRow extends ProductAnalyticsItem {
|
||||
family: CutFamilyRule;
|
||||
baseName: string;
|
||||
color: string;
|
||||
size: string;
|
||||
dailySales: number;
|
||||
projectedDemand: number;
|
||||
openProductionQuantity: number;
|
||||
availableQuantity: number;
|
||||
suggestedCutQuantity: number;
|
||||
estimatedRolls: number | null;
|
||||
daysOfCover: number | null;
|
||||
issues: CutIssue[];
|
||||
}
|
||||
|
||||
export interface CutPlanFamilySummary {
|
||||
family: CutFamilyRule;
|
||||
skuCount: number;
|
||||
colorCount: number;
|
||||
sizeCount: number;
|
||||
quantitySold: number;
|
||||
stock: number;
|
||||
projectedDemand: number;
|
||||
suggestedCutQuantity: number;
|
||||
estimatedRolls: number | null;
|
||||
}
|
||||
|
||||
export interface CutPlanSummary {
|
||||
skuCount: number;
|
||||
familiesWithNeed: number;
|
||||
colorsWithNeed: number;
|
||||
sizesWithNeed: number;
|
||||
totalSold: number;
|
||||
totalStock: number;
|
||||
projectedDemand: number;
|
||||
suggestedCutQuantity: number;
|
||||
estimatedRolls: number | null;
|
||||
rowsWithIssues: number;
|
||||
openProductionQuantity: number;
|
||||
}
|
||||
|
||||
export interface CutPlan {
|
||||
rows: CutPlanSkuRow[];
|
||||
needRows: CutPlanSkuRow[];
|
||||
familySummaries: CutPlanFamilySummary[];
|
||||
summary: CutPlanSummary;
|
||||
}
|
||||
|
||||
export const CUT_FAMILY_RULES: CutFamilyRule[] = [
|
||||
{
|
||||
key: 'BLPM',
|
||||
label: 'Moletom',
|
||||
materialLabel: 'BLPM',
|
||||
keywords: ['MOLETOM'],
|
||||
unitsPerRoll: null
|
||||
},
|
||||
{
|
||||
key: 'BLOS',
|
||||
label: 'Camiseta over',
|
||||
materialLabel: 'BLOS',
|
||||
keywords: ['OVER'],
|
||||
unitsPerRoll: null
|
||||
},
|
||||
{
|
||||
key: 'BLMC',
|
||||
label: 'Camiseta infantil',
|
||||
materialLabel: 'BLMC',
|
||||
keywords: ['INFANTIL', 'KIDS'],
|
||||
unitsPerRoll: null
|
||||
},
|
||||
{
|
||||
key: 'BLCS',
|
||||
label: 'Camiseta regular',
|
||||
materialLabel: 'BLCS',
|
||||
keywords: ['CAMISETA'],
|
||||
unitsPerRoll: null
|
||||
}
|
||||
];
|
||||
|
||||
export const OUTROS_RULE: CutFamilyRule = {
|
||||
key: 'OUTROS',
|
||||
label: 'Sem regra',
|
||||
materialLabel: 'Pendente',
|
||||
keywords: [],
|
||||
unitsPerRoll: null
|
||||
};
|
||||
|
||||
const FAMILY_RULES_BY_KEY = new Map<CutFamilyKey, CutFamilyRule>([
|
||||
...CUT_FAMILY_RULES.map(rule => [rule.key, rule] as const),
|
||||
[OUTROS_RULE.key, OUTROS_RULE]
|
||||
]);
|
||||
|
||||
export const getRangeDays = (range: DateRange) => {
|
||||
const start = new Date(range.start);
|
||||
const end = new Date(range.end);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
|
||||
};
|
||||
|
||||
const normalizeRuleText = (value: string) => (
|
||||
value
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toUpperCase()
|
||||
);
|
||||
|
||||
export const classifyCutFamily = (baseName: string): CutFamilyRule => {
|
||||
const normalizedName = normalizeRuleText(baseName);
|
||||
return CUT_FAMILY_RULES.find(rule => (
|
||||
rule.keywords.some(keyword => normalizedName.includes(normalizeRuleText(keyword)))
|
||||
)) || OUTROS_RULE;
|
||||
};
|
||||
|
||||
const ruleWithConfiguredYield = (rule: CutFamilyRule, familyYields?: Partial<Record<CutFamilyKey, number>>): CutFamilyRule => {
|
||||
const configuredYield = familyYields?.[rule.key];
|
||||
return {
|
||||
...rule,
|
||||
unitsPerRoll: configuredYield && configuredYield > 0 ? configuredYield : rule.unitsPerRoll
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMatchText = (value: string) => normalizeProductText(value)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toLowerCase();
|
||||
|
||||
const isOpenProductionOrder = (order: ProductionOrderItem) => (
|
||||
order.status === 'open' ||
|
||||
order.status === 'in_progress' ||
|
||||
['em aberto', 'aberta', 'andamento', 'em andamento'].includes(normalizeMatchText(order.status))
|
||||
);
|
||||
|
||||
export const buildOpenProductionByProductId = (
|
||||
products: ProductAnalyticsItem[],
|
||||
productionOrders: ProductionOrderItem[]
|
||||
): Record<string, number> => {
|
||||
const productById = new Map(products.map(product => [product.id, product]));
|
||||
const productByName = new Map(products.map(product => [normalizeMatchText(product.name), product]));
|
||||
const productsByVariantKey = new Map<string, ProductAnalyticsItem>();
|
||||
|
||||
products.forEach(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
const key = [
|
||||
normalizeMatchText(metadata.baseName),
|
||||
normalizeMatchText(metadata.color),
|
||||
normalizeMatchText(metadata.size)
|
||||
].join('::');
|
||||
productsByVariantKey.set(key, product);
|
||||
});
|
||||
|
||||
return productionOrders.reduce<Record<string, number>>((totals, order) => {
|
||||
if (!isOpenProductionOrder(order)) return totals;
|
||||
|
||||
const skuMatch = order.productSku ? productById.get(order.productSku) : undefined;
|
||||
const nameMatch = order.productDescription ? productByName.get(normalizeMatchText(order.productDescription)) : undefined;
|
||||
let product = skuMatch || nameMatch;
|
||||
|
||||
if (!product && order.productDescription) {
|
||||
const metadata = parseProductName(order.productDescription);
|
||||
const key = [
|
||||
normalizeMatchText(metadata.baseName),
|
||||
normalizeMatchText(metadata.color),
|
||||
normalizeMatchText(metadata.size)
|
||||
].join('::');
|
||||
product = productsByVariantKey.get(key);
|
||||
}
|
||||
|
||||
if (!product) return totals;
|
||||
|
||||
totals[product.id] = (totals[product.id] || 0) + order.quantity;
|
||||
return totals;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const buildCutPlan = (
|
||||
products: ProductAnalyticsItem[],
|
||||
dateRange: DateRange,
|
||||
targetCoverageDays: number,
|
||||
openProductionByProductId: Record<string, number> = {},
|
||||
options: CutPlanOptions = {}
|
||||
): CutPlan => {
|
||||
const rangeDays = getRangeDays(dateRange);
|
||||
|
||||
const rows = products.map<CutPlanSkuRow>(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
const override = options.productOverrides?.[product.id];
|
||||
const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined;
|
||||
const family = ruleWithConfiguredYield(overrideRule || classifyCutFamily(metadata.baseName), options.familyYields);
|
||||
const color = normalizeProductText(override?.color || metadata.color);
|
||||
const size = normalizeProductText(override?.size || metadata.size).toUpperCase();
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * targetCoverageDays;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
const availableQuantity = product.stock + openProductionQuantity;
|
||||
const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
|
||||
const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0
|
||||
? Math.ceil(suggestedCutQuantity / family.unitsPerRoll)
|
||||
: null;
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
const issues: CutIssue[] = [];
|
||||
|
||||
if (family.key === 'OUTROS') issues.push('missing_family_rule');
|
||||
if (!color) issues.push('missing_color');
|
||||
if (!size) issues.push('missing_size');
|
||||
if (suggestedCutQuantity > 0 && family.key !== 'OUTROS' && !family.unitsPerRoll) issues.push('missing_yield_rule');
|
||||
|
||||
return {
|
||||
...product,
|
||||
family,
|
||||
baseName: metadata.baseName,
|
||||
color,
|
||||
size,
|
||||
dailySales,
|
||||
projectedDemand,
|
||||
openProductionQuantity,
|
||||
availableQuantity,
|
||||
suggestedCutQuantity,
|
||||
estimatedRolls,
|
||||
daysOfCover,
|
||||
issues
|
||||
};
|
||||
});
|
||||
|
||||
const needRows = rows.filter(row => row.suggestedCutQuantity > 0);
|
||||
const familyGroups = new Map<CutFamilyKey, CutPlanSkuRow[]>();
|
||||
needRows.forEach(row => {
|
||||
const group = familyGroups.get(row.family.key) || [];
|
||||
group.push(row);
|
||||
familyGroups.set(row.family.key, group);
|
||||
});
|
||||
|
||||
const familySummaries = Array.from(familyGroups.values())
|
||||
.map<CutPlanFamilySummary>(group => {
|
||||
const first = group[0];
|
||||
const colors = new Set(group.map(row => row.color).filter(Boolean));
|
||||
const sizes = sortProductSizes(Array.from(new Set(group.map(row => row.size).filter(Boolean))));
|
||||
|
||||
return {
|
||||
family: first.family,
|
||||
skuCount: group.length,
|
||||
colorCount: colors.size,
|
||||
sizeCount: sizes.length,
|
||||
quantitySold: group.reduce((total, row) => total + row.quantitySold, 0),
|
||||
stock: group.reduce((total, row) => total + row.stock, 0),
|
||||
projectedDemand: group.reduce((total, row) => total + row.projectedDemand, 0),
|
||||
suggestedCutQuantity: group.reduce((total, row) => total + row.suggestedCutQuantity, 0),
|
||||
estimatedRolls: group.some(row => row.estimatedRolls === null)
|
||||
? null
|
||||
: group.reduce((total, row) => total + (row.estimatedRolls || 0), 0)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity);
|
||||
|
||||
const colorsWithNeed = new Set(needRows.map(row => row.color).filter(Boolean));
|
||||
const sizesWithNeed = new Set(needRows.map(row => row.size).filter(Boolean));
|
||||
|
||||
return {
|
||||
rows,
|
||||
needRows,
|
||||
familySummaries,
|
||||
summary: {
|
||||
skuCount: needRows.length,
|
||||
familiesWithNeed: familySummaries.length,
|
||||
colorsWithNeed: colorsWithNeed.size,
|
||||
sizesWithNeed: sizesWithNeed.size,
|
||||
totalSold: needRows.reduce((total, row) => total + row.quantitySold, 0),
|
||||
totalStock: needRows.reduce((total, row) => total + row.stock, 0),
|
||||
projectedDemand: needRows.reduce((total, row) => total + row.projectedDemand, 0),
|
||||
suggestedCutQuantity: needRows.reduce((total, row) => total + row.suggestedCutQuantity, 0),
|
||||
estimatedRolls: needRows.some(row => row.estimatedRolls === null)
|
||||
? null
|
||||
: needRows.reduce((total, row) => total + (row.estimatedRolls || 0), 0),
|
||||
rowsWithIssues: needRows.filter(row => row.issues.length > 0).length,
|
||||
openProductionQuantity: needRows.reduce((total, row) => total + row.openProductionQuantity, 0)
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList, ShoppingCart } from 'lucide-react';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList, ShoppingCart, Scissors } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { isSuperAdmin, logout } from '../dataService';
|
||||
import { rangeForLastDays } from '../dateRanges';
|
||||
@@ -63,6 +63,7 @@ const Layout = () => {
|
||||
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
||||
{ name: 'Produtos', href: '/products', icon: Package },
|
||||
{ name: 'Reposição', href: '/replenishment', icon: ShoppingCart },
|
||||
{ name: 'Corte', href: '/cutting', icon: Scissors },
|
||||
{ name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList },
|
||||
{ name: 'Clientes', href: '/clients', icon: Users },
|
||||
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -183,6 +183,32 @@ export const fetchProductionOrders = async (
|
||||
}, options);
|
||||
};
|
||||
|
||||
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
|
||||
try {
|
||||
const response = await authFetch('/cutting-settings');
|
||||
if (!response.ok) return { familyYields: {}, productOverrides: {} };
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch cutting settings failed', error);
|
||||
return { familyYields: {}, productOverrides: {} };
|
||||
}
|
||||
};
|
||||
|
||||
export const saveCuttingSettings = async (settings: CuttingSettings): Promise<CuttingSettings> => {
|
||||
const response = await authFetch('/cutting-settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível salvar as regras de corte.');
|
||||
}
|
||||
|
||||
return data as CuttingSettings;
|
||||
};
|
||||
|
||||
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
|
||||
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
|
||||
899
src/pages/Cutting.tsx
Normal file
899
src/pages/Cutting.tsx
Normal file
@@ -0,0 +1,899 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2 } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
|
||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||
type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||
const coverageTargetOptions = [7, 15, 30, 60];
|
||||
const familyOptions: Array<{ value: CutFamilyKey | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'Todas famílias' },
|
||||
...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })),
|
||||
{ value: 'OUTROS', label: 'Sem regra' }
|
||||
];
|
||||
|
||||
const filterOptions: Array<{ value: CutFilter; label: string }> = [
|
||||
{ value: 'need', label: 'Com necessidade' },
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'issues', label: 'Pendências' },
|
||||
{ value: 'covered', label: 'Sem necessidade' }
|
||||
];
|
||||
|
||||
const issueLabels: Record<CutIssue, string> = {
|
||||
missing_family_rule: 'Sem família',
|
||||
missing_color: 'Sem cor',
|
||||
missing_size: 'Sem tamanho',
|
||||
missing_yield_rule: 'Sem rendimento'
|
||||
};
|
||||
|
||||
const issueHelp: Record<CutIssue, string> = {
|
||||
missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC ou BLPM.',
|
||||
missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.',
|
||||
missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.',
|
||||
missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.'
|
||||
};
|
||||
|
||||
const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [
|
||||
{ value: 'all', label: 'Todas pendências' },
|
||||
{ value: 'missing_family_rule', label: issueLabels.missing_family_rule },
|
||||
{ value: 'missing_color', label: issueLabels.missing_color },
|
||||
{ value: 'missing_size', label: issueLabels.missing_size }
|
||||
];
|
||||
|
||||
const familyStyles: Record<CutFamilyKey, string> = {
|
||||
BLCS: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300',
|
||||
BLOS: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
|
||||
BLMC: 'border-amber-400/30 bg-amber-400/10 text-amber-300',
|
||||
BLPM: 'border-purple-400/30 bg-purple-400/10 text-purple-300',
|
||||
OUTROS: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300'
|
||||
};
|
||||
|
||||
const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||
);
|
||||
|
||||
const formatDays = (value: number | null) => {
|
||||
if (value === null) return '-';
|
||||
if (value > 999) return '999+ dias';
|
||||
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
|
||||
};
|
||||
|
||||
const allProductionOrdersRange = {
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2100, 11, 31)
|
||||
};
|
||||
|
||||
const loadCuttingSettings = (): CuttingSettings => {
|
||||
try {
|
||||
const rawSettings = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (!rawSettings) return { familyYields: {}, productOverrides: {} };
|
||||
const parsed = JSON.parse(rawSettings) as Partial<CuttingSettings>;
|
||||
return {
|
||||
familyYields: parsed.familyYields || {},
|
||||
productOverrides: parsed.productOverrides || {}
|
||||
};
|
||||
} catch {
|
||||
return { familyYields: {}, productOverrides: {} };
|
||||
}
|
||||
};
|
||||
|
||||
const hasCuttingSettings = (settings: CuttingSettings) => (
|
||||
Object.keys(settings.familyYields).length > 0 ||
|
||||
Object.keys(settings.productOverrides).length > 0
|
||||
);
|
||||
|
||||
const CuttingSkeleton = () => (
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando plano de corte">
|
||||
<div className="border-b border-dark-border p-4">
|
||||
<div className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-3" />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
|
||||
<div key={row} className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6 px-6 py-4">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-4" />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Cutting = () => {
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
||||
const [familyFilter, setFamilyFilter] = useState<CutFamilyKey | 'all'>('all');
|
||||
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
|
||||
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
|
||||
const [correctionPage, setCorrectionPage] = useState(1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadSettings = async () => {
|
||||
const settings = await fetchCuttingSettings();
|
||||
if (!isMounted) return;
|
||||
|
||||
if (hasCuttingSettings(settings)) {
|
||||
setCuttingSettings(settings);
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
setHasUnsavedSettings(false);
|
||||
} else {
|
||||
const localSettings = loadCuttingSettings();
|
||||
setCuttingSettings(localSettings);
|
||||
setHasUnsavedSettings(hasCuttingSettings(localSettings));
|
||||
}
|
||||
setSaveStatus('idle');
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadProducts = async () => {
|
||||
setIsLoading(true);
|
||||
const [productData, productionOrderData] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchProductionOrders(allProductionOrdersRange)
|
||||
]);
|
||||
if (isMounted) {
|
||||
setProducts(productData);
|
||||
setProductionOrders(productionOrderData.orders);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProducts();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
const openProductionByProductId = useMemo(
|
||||
() => buildOpenProductionByProductId(products, productionOrders),
|
||||
[products, productionOrders]
|
||||
);
|
||||
|
||||
const cutPlan = useMemo(
|
||||
() => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId, cuttingSettings),
|
||||
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
|
||||
);
|
||||
|
||||
const issueSummaries = useMemo(() => {
|
||||
const counts = new Map<CutIssue, number>();
|
||||
cutPlan.needRows.forEach(row => {
|
||||
row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1));
|
||||
});
|
||||
return Array.from(counts.entries()).map(([issue, count]) => ({ issue, count }));
|
||||
}, [cutPlan.needRows]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const searchedRows = normalizedSearch
|
||||
? cutPlan.rows.filter(row => (
|
||||
row.name.toLowerCase().includes(normalizedSearch) ||
|
||||
row.id.toLowerCase().includes(normalizedSearch) ||
|
||||
row.baseName.toLowerCase().includes(normalizedSearch) ||
|
||||
row.color.toLowerCase().includes(normalizedSearch)
|
||||
))
|
||||
: cutPlan.rows;
|
||||
|
||||
const familyRows = familyFilter === 'all'
|
||||
? searchedRows
|
||||
: searchedRows.filter(row => row.family.key === familyFilter);
|
||||
|
||||
const statusRows = familyRows.filter(row => {
|
||||
if (cutFilter === 'all') return true;
|
||||
if (cutFilter === 'need') return row.suggestedCutQuantity > 0;
|
||||
if (cutFilter === 'issues') return row.issues.length > 0;
|
||||
return row.suggestedCutQuantity === 0;
|
||||
});
|
||||
|
||||
return [...statusRows].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'need_asc': return a.suggestedCutQuantity - b.suggestedCutQuantity;
|
||||
case 'demand_desc': return b.projectedDemand - a.projectedDemand;
|
||||
case 'stock_asc': return a.stock - b.stock;
|
||||
case 'sold_desc': return b.quantitySold - a.quantitySold;
|
||||
case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR');
|
||||
case 'need_desc':
|
||||
default:
|
||||
return b.suggestedCutQuantity - a.suggestedCutQuantity;
|
||||
}
|
||||
});
|
||||
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
|
||||
|
||||
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage);
|
||||
const isRefreshing = isLoading && products.length > 0;
|
||||
const correctionRows = useMemo(() => {
|
||||
const rows = cutPlan.rows.filter(row => (
|
||||
row.issues.some(issue => issue !== 'missing_yield_rule') &&
|
||||
(correctionIssueFilter === 'all' || row.issues.includes(correctionIssueFilter))
|
||||
));
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
if (b.suggestedCutQuantity !== a.suggestedCutQuantity) {
|
||||
return b.suggestedCutQuantity - a.suggestedCutQuantity;
|
||||
}
|
||||
return a.name.localeCompare(b.name, 'pt-BR');
|
||||
});
|
||||
}, [correctionIssueFilter, cutPlan.rows]);
|
||||
const correctionItemsPerPage = 12;
|
||||
const correctionTotalPages = Math.ceil(correctionRows.length / correctionItemsPerPage);
|
||||
const safeCorrectionPage = Math.min(correctionPage, correctionTotalPages || 1);
|
||||
const correctionStartIndex = (safeCorrectionPage - 1) * correctionItemsPerPage;
|
||||
const paginatedCorrectionRows = correctionRows.slice(correctionStartIndex, correctionStartIndex + correctionItemsPerPage);
|
||||
const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length;
|
||||
const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length;
|
||||
|
||||
useEffect(() => {
|
||||
setCorrectionPage(1);
|
||||
}, [correctionIssueFilter, dateRange, targetCoverageDays]);
|
||||
|
||||
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
|
||||
const parsedValue = Number(value);
|
||||
setCuttingSettings(current => {
|
||||
const familyYields = { ...current.familyYields };
|
||||
if (Number.isFinite(parsedValue) && parsedValue > 0) {
|
||||
familyYields[familyKey] = parsedValue;
|
||||
} else {
|
||||
delete familyYields[familyKey];
|
||||
}
|
||||
return { ...current, familyYields };
|
||||
});
|
||||
setHasUnsavedSettings(true);
|
||||
setSaveStatus('idle');
|
||||
};
|
||||
|
||||
const updateProductOverride = (productId: string, patch: CutProductOverride) => {
|
||||
setCuttingSettings(current => {
|
||||
const currentOverride = current.productOverrides[productId] || {};
|
||||
const nextOverride = { ...currentOverride, ...patch };
|
||||
const normalizedOverride: CutProductOverride = {
|
||||
familyKey: nextOverride.familyKey || '',
|
||||
color: nextOverride.color || '',
|
||||
size: nextOverride.size || ''
|
||||
};
|
||||
const productOverrides = { ...current.productOverrides };
|
||||
if (!normalizedOverride.familyKey && !normalizedOverride.color && !normalizedOverride.size) {
|
||||
delete productOverrides[productId];
|
||||
} else {
|
||||
productOverrides[productId] = normalizedOverride;
|
||||
}
|
||||
return { ...current, productOverrides };
|
||||
});
|
||||
setHasUnsavedSettings(true);
|
||||
setSaveStatus('idle');
|
||||
};
|
||||
|
||||
const clearProductOverride = (productId: string) => {
|
||||
setCuttingSettings(current => {
|
||||
const productOverrides = { ...current.productOverrides };
|
||||
delete productOverrides[productId];
|
||||
return { ...current, productOverrides };
|
||||
});
|
||||
setHasUnsavedSettings(true);
|
||||
setSaveStatus('idle');
|
||||
};
|
||||
|
||||
const persistSettings = async (settings = cuttingSettings) => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const savedSettings = await saveCuttingSettings(settings);
|
||||
setCuttingSettings(savedSettings);
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings));
|
||||
setHasUnsavedSettings(false);
|
||||
setSaveStatus('saved');
|
||||
} catch (error) {
|
||||
console.error('Save cutting settings failed', error);
|
||||
setSaveStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const clearSettings = () => {
|
||||
const emptySettings = { familyYields: {}, productOverrides: {} };
|
||||
setCuttingSettings(emptySettings);
|
||||
setHasUnsavedSettings(true);
|
||||
setSaveStatus('idle');
|
||||
};
|
||||
|
||||
const exportRows = () => {
|
||||
exportToCSV(filteredRows.map(row => ({
|
||||
'ID Produto': row.id,
|
||||
'Descrição': row.name,
|
||||
'Família': row.family.label,
|
||||
'Material': row.family.materialLabel,
|
||||
'Cor': row.color,
|
||||
'Tamanho': row.size,
|
||||
'Vendido no período': row.quantitySold,
|
||||
'Média diária': row.dailySales.toFixed(2).replace('.', ','),
|
||||
'Demanda projetada': row.projectedDemand.toFixed(2).replace('.', ','),
|
||||
'Estoque': row.stock,
|
||||
'OP aberta': row.openProductionQuantity,
|
||||
'Disponível': row.availableQuantity,
|
||||
'Necessidade corte': row.suggestedCutQuantity,
|
||||
'Rendimento un/rolo': row.family.unitsPerRoll || '',
|
||||
'Rolos estimados': row.estimatedRolls || '',
|
||||
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
||||
'Pendências': row.issues.map(issue => issueLabels[issue]).join(' | ')
|
||||
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
};
|
||||
|
||||
const renderIssueBadge = (row: CutPlanSkuRow) => {
|
||||
if (!row.issues.length) {
|
||||
return <span className="text-xs font-bold text-emerald-300">OK</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{row.issues.map(issue => issueLabels[issue]).join(', ')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Plano de Corte</h1>
|
||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Necessidade por família, cor e tamanho calculada com vendas, estoque e cobertura alvo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportRows}
|
||||
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
|
||||
title="Exportar para CSV"
|
||||
>
|
||||
<Download size={16} className="text-brand-primary" />
|
||||
<span className="hidden sm:inline">Exportar</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsOpen(current => !current)}
|
||||
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
|
||||
title="Configurar regras de corte"
|
||||
>
|
||||
<Settings2 size={16} className="text-brand-primary" />
|
||||
<span className="hidden sm:inline">Regras</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
|
||||
{isSettingsOpen && (
|
||||
<div className="space-y-4 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-dark-text">Regras de Corte</h2>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSettings}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Limpar regras
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void persistSettings()}
|
||||
disabled={saveStatus === 'saving' || !hasUnsavedSettings}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-3 py-2 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{saveStatus === 'saving' ? 'Salvando' : 'Salvar regras'}
|
||||
</button>
|
||||
</div>
|
||||
{saveStatus === 'saved' && (
|
||||
<p className="text-xs font-semibold text-emerald-300">Regras salvas no banco de dados.</p>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<p className="text-xs font-semibold text-red-300">Não foi possível salvar as regras. Tente novamente.</p>
|
||||
)}
|
||||
{hasUnsavedSettings && saveStatus !== 'saving' && (
|
||||
<p className="text-xs font-semibold text-amber-300">Existem alterações ainda não salvas.</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
{CUT_FAMILY_RULES.map(rule => (
|
||||
<label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3">
|
||||
<span className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold text-dark-text">{rule.materialLabel}</span>
|
||||
<span className="text-[11px] font-semibold text-dark-muted">{rule.label}</span>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={cuttingSettings.familyYields[rule.key] || ''}
|
||||
onChange={(event) => updateFamilyYield(rule.key, event.target.value)}
|
||||
placeholder="un./rolo"
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dark-border">
|
||||
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-header p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Correções de produto</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
Use quando o nome do SKU não deixa claro família, cor ou tamanho para montar o corte.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span className="text-xs font-bold text-dark-muted">
|
||||
{formatNumber(correctionRows.length)} produtos
|
||||
</span>
|
||||
<select
|
||||
value={correctionIssueFilter}
|
||||
onChange={(event) => {
|
||||
setCorrectionIssueFilter(event.target.value as CorrectionIssueFilter);
|
||||
setCorrectionPage(1);
|
||||
}}
|
||||
className="h-9 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{correctionFilterOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{correctionRows.length ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[760px]">
|
||||
<div className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] gap-3 border-b border-dark-border px-4 py-3 text-[10px] font-bold uppercase tracking-wider text-dark-muted">
|
||||
<span>ID</span>
|
||||
<span>Produto</span>
|
||||
<span>Família</span>
|
||||
<span>Cor</span>
|
||||
<span>Tamanho</span>
|
||||
<span className="text-right">Ações</span>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{paginatedCorrectionRows.map(row => {
|
||||
const override = cuttingSettings.productOverrides[row.id] || {};
|
||||
return (
|
||||
<div key={row.id} className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] items-center gap-3 px-4 py-3">
|
||||
<span className="font-mono text-[11px] text-dark-muted">#{row.id}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{row.issues.filter(issue => issue !== 'missing_yield_rule').map(issue => (
|
||||
<span key={issue} className="rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300">
|
||||
{issueLabels[issue]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={override.familyKey || ''}
|
||||
onChange={(event) => updateProductOverride(row.id, { familyKey: event.target.value as CutFamilyKey | '' })}
|
||||
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<option value="">Auto</option>
|
||||
{familyOptions.filter(option => option.value !== 'all').map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={override.color || ''}
|
||||
onChange={(event) => updateProductOverride(row.id, { color: event.target.value })}
|
||||
placeholder={row.color || 'Cor'}
|
||||
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={override.size || ''}
|
||||
onChange={(event) => updateProductOverride(row.id, { size: event.target.value })}
|
||||
placeholder={row.size || 'Tam.'}
|
||||
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearProductOverride(row.id)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||
title="Limpar correção"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationControls
|
||||
totalItems={correctionRows.length}
|
||||
currentPage={safeCorrectionPage}
|
||||
totalPages={correctionTotalPages}
|
||||
pageSize={correctionItemsPerPage}
|
||||
pageSizeOptions={[12]}
|
||||
itemLabel="produtos"
|
||||
pageSizeLabel="por página"
|
||||
startIndex={correctionStartIndex}
|
||||
endIndex={Math.min(correctionStartIndex + correctionItemsPerPage, correctionRows.length)}
|
||||
onPageChange={setCorrectionPage}
|
||||
onPageSizeChange={() => setCorrectionPage(1)}
|
||||
className="border-t border-dark-border px-4 py-3"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-sm font-bold text-dark-text">Nenhuma correção de produto pendente.</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">As pendências restantes, se existirem, são de rendimento por família.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Unidades a cortar</p>
|
||||
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutPlan.summary.suggestedCutQuantity)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutPlan.summary.skuCount)} SKUs com necessidade</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-400/25 bg-red-400/10 p-3 text-red-300">
|
||||
<Scissors className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
|
||||
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{cutPlan.summary.estimatedRolls === null ? 'Rolos pendentes de rendimento' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
|
||||
<Layers3 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cores / tamanhos</p>
|
||||
<p className="mt-2 text-3xl font-bold text-sky-300">
|
||||
{formatNumber(cutPlan.summary.colorsWithNeed)} / {formatNumber(cutPlan.summary.sizesWithNeed)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Com corte sugerido</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
|
||||
<Palette className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OP aberta</p>
|
||||
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutPlan.summary.openProductionQuantity)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Unidades abatidas quando há match</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!issueSummaries.length && (
|
||||
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/5 p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-amber-200">Pendências para fechar o Corte</h2>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
Estes pontos substituem as correções manuais que antes ficavam espalhadas no Excel.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-bold text-amber-300">
|
||||
{formatNumber(cutPlan.summary.rowsWithIssues)} SKUs
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
{issueSummaries.map(summary => (
|
||||
<button
|
||||
key={summary.issue}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCutFilter('issues');
|
||||
setIsSettingsOpen(true);
|
||||
if (summary.issue !== 'missing_yield_rule') {
|
||||
setCorrectionIssueFilter(summary.issue);
|
||||
}
|
||||
setCurrentPage(1);
|
||||
setCorrectionPage(1);
|
||||
}}
|
||||
className="rounded-xl border border-dark-border bg-dark-card p-3 text-left transition-colors hover:border-amber-400/50 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span>
|
||||
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!cutPlan.familySummaries.length && (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-4">
|
||||
{cutPlan.familySummaries.map(summary => (
|
||||
<div key={summary.family.key} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[summary.family.key]}`}>
|
||||
{summary.family.materialLabel}
|
||||
</span>
|
||||
<h3 className="mt-3 text-sm font-bold text-dark-text">{summary.family.label}</h3>
|
||||
</div>
|
||||
<Scissors className="h-5 w-5 text-dark-muted" />
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold text-dark-text">{formatNumber(summary.suggestedCutQuantity)} un.</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{formatNumber(summary.skuCount)} SKUs · {formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nome, ID, grupo ou cor..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-dark-text transition-colors hover:border-brand-primary focus:border-brand-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={targetCoverageDays}
|
||||
onChange={(event) => {
|
||||
setTargetCoverageDays(Number(event.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
||||
aria-label="Dias de cobertura alvo"
|
||||
>
|
||||
{coverageTargetOptions.map(days => <option key={days} value={days}>Cobrir {days} dias</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={familyFilter}
|
||||
onChange={(event) => {
|
||||
setFamilyFilter(event.target.value as CutFamilyKey | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
||||
>
|
||||
{familyOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={cutFilter}
|
||||
onChange={(event) => {
|
||||
setCutFilter(event.target.value as CutFilter);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
||||
>
|
||||
{filterOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(event) => {
|
||||
setSortBy(event.target.value as CutSort);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="need_desc">Maior necessidade</option>
|
||||
<option value="need_asc">Menor necessidade</option>
|
||||
<option value="demand_desc">Maior demanda projetada</option>
|
||||
<option value="stock_asc">Menor estoque</option>
|
||||
<option value="sold_desc">Mais vendidos</option>
|
||||
<option value="name_asc">Nome A-Z</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading && products.length === 0 ? (
|
||||
<CuttingSkeleton />
|
||||
) : (
|
||||
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1420px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[360px]" />
|
||||
<col className="w-[150px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[100px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[110px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[150px]" />
|
||||
<col className="w-[110px]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">ID Produto</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Descrição</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Família</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Cor</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tamanho</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Demanda proj.</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Disponível</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
|
||||
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||
{paginatedRows.map(row => (
|
||||
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
|
||||
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
|
||||
Média: {formatNumber(row.dailySales, 2)} un./dia · Cobertura: {formatDays(row.daysOfCover)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
|
||||
{row.family.materialLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<span className="inline-flex max-w-full items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
|
||||
<Palette className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{row.color || '-'}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
|
||||
<Ruler className="h-3.5 w-3.5" />
|
||||
{row.size || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
|
||||
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">{formatNumber(row.stock)} un.</td>
|
||||
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
|
||||
{formatNumber(row.availableQuantity)} un.
|
||||
{!!row.openProductionQuantity && <span className="ml-1 text-xs text-dark-muted">incl. OP</span>}
|
||||
</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap">
|
||||
<span className={row.suggestedCutQuantity > 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}>
|
||||
{formatNumber(row.suggestedCutQuantity)} un.
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
|
||||
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
|
||||
</td>
|
||||
<td className="px-6 py-2.5">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<Link
|
||||
to={`/products/${row.id}`}
|
||||
className="inline-flex items-center whitespace-nowrap rounded-lg bg-brand-primary/10 px-3 py-1.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Package className="mr-1.5 h-3.5 w-3.5" />
|
||||
Ver SKU
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!filteredRows.length && (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Scissors className="mx-auto h-10 w-10 text-dark-muted" />
|
||||
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum item encontrado.</p>
|
||||
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca, família, status ou período selecionado.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaginationControls
|
||||
totalItems={filteredRows.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="SKUs"
|
||||
pageSizeLabel="itens por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, filteredRows.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setItemsPerPage(pageSize);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cutting;
|
||||
@@ -4,8 +4,9 @@ import { AlertTriangle, CheckCircle2, Download, Package, Search, TrendingUp } fr
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { parseProductName, sortProductSizes } from '../productParsing';
|
||||
|
||||
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
|
||||
@@ -17,6 +18,8 @@ type ReplenishmentRow = ProductAnalyticsItem & {
|
||||
dailySales: number;
|
||||
projectedDemand: number;
|
||||
suggestedQuantity: number;
|
||||
openProductionQuantity: number;
|
||||
availableQuantity: number;
|
||||
daysOfCover: number | null;
|
||||
status: ReplenishmentStatus;
|
||||
statusLabel: string;
|
||||
@@ -79,6 +82,11 @@ const getRangeDays = (range: DateRange) => {
|
||||
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
|
||||
};
|
||||
|
||||
const allProductionOrdersRange = {
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2100, 11, 31)
|
||||
};
|
||||
|
||||
const ReplenishmentSkeleton = () => (
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando necessidade de reposicao">
|
||||
<div className="border-b border-zinc-100 p-4 dark:border-dark-border">
|
||||
@@ -115,6 +123,7 @@ const Replenishment = () => {
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ReplenishmentFilter>(() => {
|
||||
@@ -132,10 +141,14 @@ const Replenishment = () => {
|
||||
|
||||
const loadProducts = async () => {
|
||||
setIsLoading(true);
|
||||
const data = await fetchProductAnalytics(dateRange);
|
||||
const [productData, productionOrderData] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchProductionOrders(allProductionOrdersRange)
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setProducts(data);
|
||||
setProducts(productData);
|
||||
setProductionOrders(productionOrderData.orders);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -147,16 +160,23 @@ const Replenishment = () => {
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
const openProductionByProductId = useMemo(
|
||||
() => buildOpenProductionByProductId(products, productionOrders),
|
||||
[products, productionOrders]
|
||||
);
|
||||
|
||||
const allRows = useMemo<ReplenishmentRow[]>(() => {
|
||||
const rangeDays = getRangeDays(dateRange);
|
||||
|
||||
return products.map(product => {
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * targetCoverageDays;
|
||||
const rawNeed = projectedDemand - product.stock;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
const availableQuantity = product.stock + openProductionQuantity;
|
||||
const rawNeed = projectedDemand - availableQuantity;
|
||||
const suggestedQuantity = Math.max(0, Math.ceil(rawNeed));
|
||||
const daysOfCover = dailySales > 0 ? product.stock / dailySales : null;
|
||||
const status: ReplenishmentStatus = product.stock <= 0
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
const status: ReplenishmentStatus = availableQuantity <= 0
|
||||
? 'no_stock'
|
||||
: dailySales <= 0
|
||||
? 'no_sales'
|
||||
@@ -171,6 +191,8 @@ const Replenishment = () => {
|
||||
dailySales,
|
||||
projectedDemand,
|
||||
suggestedQuantity,
|
||||
openProductionQuantity,
|
||||
availableQuantity,
|
||||
daysOfCover,
|
||||
status,
|
||||
statusLabel: statusStyles[status].label,
|
||||
@@ -182,7 +204,7 @@ const Replenishment = () => {
|
||||
sizes: metadata.size ? [metadata.size] : []
|
||||
};
|
||||
});
|
||||
}, [dateRange, products, targetCoverageDays]);
|
||||
}, [dateRange, openProductionByProductId, products, targetCoverageDays]);
|
||||
|
||||
const groupedRows = useMemo<ReplenishmentRow[]>(() => {
|
||||
const groups = new Map<string, ReplenishmentRow[]>();
|
||||
@@ -199,12 +221,14 @@ const Replenishment = () => {
|
||||
const quantitySold = group.reduce((total, row) => total + row.quantitySold, 0);
|
||||
const revenue = group.reduce((total, row) => total + row.revenue, 0);
|
||||
const stock = group.reduce((total, row) => total + row.stock, 0);
|
||||
const openProductionQuantity = group.reduce((total, row) => total + row.openProductionQuantity, 0);
|
||||
const availableQuantity = group.reduce((total, row) => total + row.availableQuantity, 0);
|
||||
const dailySales = group.reduce((total, row) => total + row.dailySales, 0);
|
||||
const projectedDemand = group.reduce((total, row) => total + row.projectedDemand, 0);
|
||||
const suggestedQuantity = group.reduce((total, row) => total + row.suggestedQuantity, 0);
|
||||
const orderLineCount = group.reduce((total, row) => total + row.orderLineCount, 0);
|
||||
const daysOfCover = dailySales > 0 ? stock / dailySales : null;
|
||||
const status: ReplenishmentStatus = stock <= 0
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
const status: ReplenishmentStatus = availableQuantity <= 0
|
||||
? 'no_stock'
|
||||
: dailySales <= 0
|
||||
? 'no_sales'
|
||||
@@ -222,6 +246,8 @@ const Replenishment = () => {
|
||||
quantitySold,
|
||||
revenue,
|
||||
stock,
|
||||
openProductionQuantity,
|
||||
availableQuantity,
|
||||
orderLineCount,
|
||||
dailySales,
|
||||
projectedDemand,
|
||||
@@ -317,6 +343,8 @@ const Replenishment = () => {
|
||||
'Media Diaria': row.dailySales.toFixed(2).replace('.', ','),
|
||||
'Demanda Projetada': row.projectedDemand.toFixed(2).replace('.', ','),
|
||||
'Estoque Atual': row.stock,
|
||||
'OP Aberta': row.openProductionQuantity,
|
||||
'Disponivel': row.availableQuantity,
|
||||
'Cobertura Atual': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
||||
'Sugestao Reposicao': row.suggestedQuantity
|
||||
}));
|
||||
@@ -481,6 +509,7 @@ const Replenishment = () => {
|
||||
<colgroup>
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[390px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[120px]" />
|
||||
@@ -495,6 +524,7 @@ const Replenishment = () => {
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Demanda proj.</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Disponível</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Sugestão reposição</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Cobertura</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
|
||||
@@ -524,6 +554,12 @@ const Replenishment = () => {
|
||||
</td>
|
||||
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.projectedDemand, 1)} un.</td>
|
||||
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.stock)} un.</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap">
|
||||
<span className="font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.availableQuantity)} un.</span>
|
||||
{!!row.openProductionQuantity && (
|
||||
<span className="ml-1 text-xs font-semibold text-dark-muted">OP {formatNumber(row.openProductionQuantity)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap">
|
||||
<span className={row.suggestedQuantity > 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}>
|
||||
{formatNumber(row.suggestedQuantity)} un.
|
||||
|
||||
13
src/types.ts
13
src/types.ts
@@ -65,6 +65,19 @@ export interface ProductionOrderSummary {
|
||||
counts: ProductionOrderCounts;
|
||||
}
|
||||
|
||||
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
|
||||
|
||||
export interface CutProductOverride {
|
||||
familyKey?: CutFamilyKey | '';
|
||||
color?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface CuttingSettings {
|
||||
familyYields: Partial<Record<CutFamilyKey, number>>;
|
||||
productOverrides: Record<string, CutProductOverride>;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
||||
Reference in New Issue
Block a user