Merge pull request #3 from bimbej/#3-Analiza-sprzedaży-AI
#3 analiza sprzedaży ai
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@
|
|||||||
!*.gif
|
!*.gif
|
||||||
!*.jpg
|
!*.jpg
|
||||||
!*.png
|
!*.png
|
||||||
|
# allow python
|
||||||
|
!*.py
|
||||||
|
|
||||||
# ...even if they are in subdirectories
|
# ...even if they are in subdirectories
|
||||||
!*/
|
!*/
|
||||||
|
|||||||
@@ -313,3 +313,131 @@ function brecho($msg)
|
|||||||
var_dump($msg);
|
var_dump($msg);
|
||||||
echo '<br><br>';
|
echo '<br><br>';
|
||||||
}
|
}
|
||||||
|
// AI analysis
|
||||||
|
function createCSVReports()
|
||||||
|
{
|
||||||
|
{
|
||||||
|
$db = $GLOBALS['db'];
|
||||||
|
$exportDir = __DIR__ . "/export";
|
||||||
|
|
||||||
|
$jobs = [
|
||||||
|
[
|
||||||
|
'sql' => "
|
||||||
|
SELECT
|
||||||
|
i.document_no,
|
||||||
|
i.register_date,
|
||||||
|
i.parent_name,
|
||||||
|
p.code,
|
||||||
|
p.name,
|
||||||
|
p.group_ks,
|
||||||
|
ii.quantity,
|
||||||
|
ii.price_netto
|
||||||
|
FROM ecminvoiceouts AS i
|
||||||
|
INNER JOIN ecminvoiceoutitems AS ii ON i.id = ii.ecminvoiceout_id
|
||||||
|
INNER JOIN ecmproducts AS p ON ii.ecmproduct_id = p.id
|
||||||
|
WHERE i.type = 'normal' AND YEAR(i.register_date) = 2024
|
||||||
|
ORDER BY i.register_date DESC
|
||||||
|
",
|
||||||
|
'filename' => 'invoices_2024_' . date('Ymd_His') . '.csv',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sql' => "
|
||||||
|
SELECT code, name, SUM(ii.quantity) AS units, SUM(ii.price_netto*ii.quantity) AS revenue
|
||||||
|
FROM ecminvoiceoutitems ii
|
||||||
|
JOIN ecmproducts p ON p.id = ii.ecmproduct_id
|
||||||
|
GROUP BY code, name
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 100
|
||||||
|
",
|
||||||
|
'filename' => 'top_products_' . date('Ymd_His') . '.csv',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'sql' =>"SELECT COUNT(*) FROM ecminvoiceouts WHERE YEAR(register_date)=2025",
|
||||||
|
'filename' => 'ecminvoiceouts_2025_' . date('Ymd_His') . '.csv',
|
||||||
|
],
|
||||||
|
// ... dopisz kolejne zestawienia ...
|
||||||
|
];
|
||||||
|
|
||||||
|
$report = [];
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
$sql = $job['sql'];
|
||||||
|
$filename = $job['filename'];
|
||||||
|
$headers = isset($job['headers']) ? $job['headers'] : null;
|
||||||
|
|
||||||
|
$res = $db->query($sql);
|
||||||
|
$fullpath = rtrim($exportDir, "/") . "/" . $filename;
|
||||||
|
|
||||||
|
$result = exportToCSVFile($res, $fullpath, $headers, ';', true);
|
||||||
|
|
||||||
|
if ($result['ok']) {
|
||||||
|
$report[] = "OK → {$result['path']} (wiersze: {$result['rows']}) <br/>";
|
||||||
|
} else {
|
||||||
|
$report[] = "ERR → {$result['path']} ({$result['error']}) <br/>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo implode("\n", $report);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function exportToCSVFile($res, $fullpath, array $headers = null, $delimiter = ';', $withBom = true)
|
||||||
|
{
|
||||||
|
$db = $GLOBALS['db'];
|
||||||
|
|
||||||
|
$dir = dirname($fullpath);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
if (!@mkdir($dir, 0775, true)) {
|
||||||
|
return ['ok'=>false, 'path'=>$fullpath, 'rows'=>0, 'error'=>"Nie mogę utworzyć katalogu: $dir"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_writable($dir)) {
|
||||||
|
return ['ok'=>false, 'path'=>$fullpath, 'rows'=>0, 'error'=>"Katalog nie jest zapisywalny: $dir"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fp = @fopen($fullpath, 'w');
|
||||||
|
if ($fp === false) {
|
||||||
|
return ['ok'=>false, 'path'=>$fullpath, 'rows'=>0, 'error'=>"Nie mogę otworzyć pliku do zapisu: $fullpath"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// BOM dla Excel PL
|
||||||
|
if ($withBom) {
|
||||||
|
fwrite($fp, "\xEF\xBB\xBF");
|
||||||
|
}
|
||||||
|
|
||||||
|
// pobierz pierwszy wiersz, by ewentualnie zbudować nagłówki
|
||||||
|
$first = $db->fetchByAssoc($res);
|
||||||
|
|
||||||
|
// brak danych → pusty plik z ewentualnym nagłówkiem (jeśli podany ręcznie)
|
||||||
|
if (!$first) {
|
||||||
|
if ($headers !== null) {
|
||||||
|
fputcsv($fp, $headers, $delimiter);
|
||||||
|
}
|
||||||
|
fclose($fp);
|
||||||
|
return ['ok'=>true, 'path'=>$fullpath, 'rows'=>0, 'error'=>null];
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamiczne nagłówki, jeśli nie podano
|
||||||
|
if ($headers === null) {
|
||||||
|
$headers = array_keys($first);
|
||||||
|
}
|
||||||
|
|
||||||
|
// wypisz nagłówki
|
||||||
|
fputcsv($fp, $headers, $delimiter);
|
||||||
|
|
||||||
|
// zapisz pierwszy wiersz w kolejności nagłówków
|
||||||
|
$line = [];
|
||||||
|
foreach ($headers as $h) { $line[] = isset($first[$h]) ? $first[$h] : ''; }
|
||||||
|
fputcsv($fp, $line, $delimiter);
|
||||||
|
$count = 1;
|
||||||
|
|
||||||
|
// pozostałe wiersze
|
||||||
|
while ($row = $db->fetchByAssoc($res)) {
|
||||||
|
$line = [];
|
||||||
|
foreach ($headers as $h) { $line[] = isset($row[$h]) ? $row[$h] : ''; }
|
||||||
|
fputcsv($fp, $line, $delimiter);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($fp);
|
||||||
|
return ['ok'=>true, 'path'=>$fullpath, 'rows'=>$count, 'error'=>null];
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@
|
|||||||
case 'createCostDocumentFromInvoice':
|
case 'createCostDocumentFromInvoice':
|
||||||
createCostDocumentFromInvoice($_GET['record']);
|
createCostDocumentFromInvoice($_GET['record']);
|
||||||
break;
|
break;
|
||||||
|
case 'createCSVReports':
|
||||||
|
createCSVReports();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
// https://crm.twinpol.com/REST/index.php?key=d68dac4c-f784-4e1b-8267-9ffcfa0eda4c&action=createCostDocumentFromInvoice&record=c3f6eaa6-0cbd-8c89-1a8c-683ff19a36db
|
// https://crm.twinpol.com/REST/index.php?key=d68dac4c-f784-4e1b-8267-9ffcfa0eda4c&action=createCostDocumentFromInvoice&record=c3f6eaa6-0cbd-8c89-1a8c-683ff19a36db
|
||||||
?>
|
?>
|
||||||
@@ -104,3 +104,5 @@ if(!defined('sugarEntry') || !sugarEntry) die('Not A Valid Entry Point');
|
|||||||
if(ACLController::checkAccess('EcmInvoiceOuts', "list", true)) $module_menu [] = Array("index.php?module=EcmInvoiceOuts&action=Report_INTRASTAT", "Raport INTRASTAT","EcmInvoiceOuts", 'EcmInvoiceOuts');
|
if(ACLController::checkAccess('EcmInvoiceOuts', "list", true)) $module_menu [] = Array("index.php?module=EcmInvoiceOuts&action=Report_INTRASTAT", "Raport INTRASTAT","EcmInvoiceOuts", 'EcmInvoiceOuts');
|
||||||
|
|
||||||
if(ACLController::checkAccess('EcmInvoiceOuts', "list", true)) $module_menu [] = Array("index.php?module=EcmInvoiceOuts&action=ecommerce", "Faktury E-Commerce","EcmInvoiceOuts", 'EcmInvoiceOuts');
|
if(ACLController::checkAccess('EcmInvoiceOuts', "list", true)) $module_menu [] = Array("index.php?module=EcmInvoiceOuts&action=ecommerce", "Faktury E-Commerce","EcmInvoiceOuts", 'EcmInvoiceOuts');
|
||||||
|
|
||||||
|
if(ACLController::checkAccess('EcmInvoiceOuts', "list", true)) $module_menu [] = Array("index.php?module=EcmInvoiceOuts&action=bimit_invoiceSummary", "Analiza faktur","EcmInvoiceOuts", 'EcmInvoiceOuts');
|
||||||
21
modules/EcmInvoiceOuts/ai/enqueue.php
Normal file
21
modules/EcmInvoiceOuts/ai/enqueue.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
// modules/EcmInvoiceOuts/ai/enqueue.php
|
||||||
|
$from = $_POST['from'] ?? null;
|
||||||
|
$to = $_POST['to'] ?? null;
|
||||||
|
$currency = $_POST['currency'] ?? 'PLN';
|
||||||
|
$axis = $_POST['axis'] ?? 'sku_id';
|
||||||
|
$label = $_POST['label'] ?? 'sku_name';
|
||||||
|
$top_n = (int)($_POST['top_n'] ?? 50);
|
||||||
|
$goal = $_POST['goal'] ?? 'porównanie Q2 vs Q1';
|
||||||
|
|
||||||
|
if (!$from || !$to) { http_response_code(400); exit('Missing from/to'); }
|
||||||
|
|
||||||
|
$base = __DIR__;
|
||||||
|
@mkdir("$base/queue", 0777, true);
|
||||||
|
|
||||||
|
$payload = compact('from','to','currency','axis','label','top_n','goal');
|
||||||
|
$id = bin2hex(random_bytes(8));
|
||||||
|
file_put_contents("$base/queue/$id.json", json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['job_id' => $id]);
|
||||||
12
modules/EcmInvoiceOuts/ai/result.php
Normal file
12
modules/EcmInvoiceOuts/ai/result.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
// modules/EcmInvoiceOuts/ai/result.php
|
||||||
|
$base = __DIR__;
|
||||||
|
$files = glob("$base/out/*.json");
|
||||||
|
rsort($files);
|
||||||
|
$latest = $files[0] ?? null;
|
||||||
|
|
||||||
|
if (!$latest) { http_response_code(404); exit('Brak wyników'); }
|
||||||
|
|
||||||
|
$payload = json_decode(file_get_contents($latest), true);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||||
37
modules/EcmInvoiceOuts/ai/test.py
Normal file
37
modules/EcmInvoiceOuts/ai/test.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import mysql.connector
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("MySQL connector not available: %s\n" % e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def getenv(key, default=None):
|
||||||
|
return os.environ.get(key, default)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = {
|
||||||
|
"host": getenv("MYSQL_HOST", "twinpol-mysql56"),
|
||||||
|
"user": getenv("MYSQL_USER", "root"),
|
||||||
|
"password": getenv("MYSQL_PASSWORD", "rootpassword"),
|
||||||
|
"database": getenv("MYSQL_DATABASE", "preDb_0dcc87940d3655fa574b253df04ca1c3"),
|
||||||
|
"port": int(getenv("MYSQL_PORT", "3306")),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
cnx = mysql.connector.connect(**cfg)
|
||||||
|
cur = cnx.cursor()
|
||||||
|
cur.execute("SELECT COUNT(*) FROM ecminvoiceouts WHERE YEAR(register_date)=2025")
|
||||||
|
row = cur.fetchone()
|
||||||
|
count = int(row[0]) if row and row[0] is not None else 0
|
||||||
|
print(count)
|
||||||
|
cur.close()
|
||||||
|
cnx.close()
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write("Query error: %s\n" % e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
141
modules/EcmInvoiceOuts/ai/worker.py
Normal file
141
modules/EcmInvoiceOuts/ai/worker.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# worker.py
|
||||||
|
import os, json, io, uuid
|
||||||
|
import datetime as dt
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
import pymysql
|
||||||
|
from tenacity import retry, wait_exponential, stop_after_attempt
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
AI_MODEL = os.getenv("AI_MODEL", "gpt-5-pro")
|
||||||
|
AI_API_KEY = os.getenv("AI_API_KEY")
|
||||||
|
|
||||||
|
MYSQL_CONF = dict(
|
||||||
|
host=os.getenv("MYSQL_HOST", "localhost"),
|
||||||
|
user=os.getenv("MYSQL_USER", "root"),
|
||||||
|
password=os.getenv("MYSQL_PASSWORD", ""),
|
||||||
|
database=os.getenv("MYSQL_DB", "sales"),
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mysql_query(sql: str, params: tuple = ()) -> pl.DataFrame:
|
||||||
|
conn = pymysql.connect(**MYSQL_CONF)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(sql, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return pl.from_dicts(rows)
|
||||||
|
|
||||||
|
def to_csv(df: pl.DataFrame) -> str:
|
||||||
|
buf = io.StringIO()
|
||||||
|
df.write_csv(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
SQL_KPIS_DAILY = """
|
||||||
|
SELECT DATE(invoice_date) AS d,
|
||||||
|
SUM(net_amount) AS revenue,
|
||||||
|
SUM(quantity) AS qty,
|
||||||
|
ROUND(100*SUM(net_amount - cost_amount)/NULLIF(SUM(net_amount),0), 2) AS gross_margin_pct,
|
||||||
|
ROUND(100*SUM(discount_amount)/NULLIF(SUM(gross_amount),0), 2) AS discount_pct
|
||||||
|
FROM fact_invoices
|
||||||
|
WHERE invoice_date BETWEEN %s AND %s
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1;
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQL_TOP_SEGMENTS = """
|
||||||
|
SELECT {axis} AS key,
|
||||||
|
ANY_VALUE({label}) AS label,
|
||||||
|
SUM(net_amount) AS revenue,
|
||||||
|
SUM(quantity) AS qty,
|
||||||
|
ROUND(100*SUM(net_amount - cost_amount)/NULLIF(SUM(net_amount),0), 2) AS gross_margin_pct,
|
||||||
|
ROUND(100*(SUM(net_amount) - LAG(SUM(net_amount)) OVER(ORDER BY 1))/
|
||||||
|
NULLIF(LAG(SUM(net_amount)) OVER(ORDER BY 1),0), 2) AS trend_30d
|
||||||
|
FROM fact_invoices
|
||||||
|
WHERE invoice_date BETWEEN DATE_SUB(%s, INTERVAL 60 DAY) AND %s
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT %s;
|
||||||
|
"""
|
||||||
|
|
||||||
|
class AIClient:
|
||||||
|
def __init__(self, api_key: str): self.api_key = api_key
|
||||||
|
@retry(wait=wait_exponential(multiplier=1, min=1, max=20), stop=stop_after_attempt(6))
|
||||||
|
def structured_analysis(self, prompt: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# TODO: PODMIEŃ na realne wywołanie modelu z "Structured Outputs"
|
||||||
|
raise NotImplementedError("Wire your model SDK here")
|
||||||
|
|
||||||
|
@retry(wait=wait_exponential(multiplier=1, min=1, max=20), stop=stop_after_attempt(6))
|
||||||
|
def batch_submit(self, ndjson_lines: List[str]) -> str:
|
||||||
|
# TODO: PODMIEŃ na rzeczywiste Batch API
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def run_online(from_date: str, to_date: str, currency: str, axis: str, label: str, top_n: int, goal: str) -> Dict[str, Any]:
|
||||||
|
kpis = mysql_query(SQL_KPIS_DAILY, (from_date, to_date))
|
||||||
|
top = mysql_query(SQL_TOP_SEGMENTS.format(axis=axis, label=label), (from_date, to_date, top_n))
|
||||||
|
|
||||||
|
csv_blocks = ("## kpis_daily\n" + to_csv(kpis) + "\n\n" +
|
||||||
|
"## top_segments\n" + to_csv(top))
|
||||||
|
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), "sales-analysis.schema.json"), "r", encoding="utf-8") as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
Jesteś analitykiem sprzedaży. Otrzymasz: (a) kontekst, (b) dane.
|
||||||
|
Zwróć **wyłącznie** JSON zgodny ze schema.
|
||||||
|
|
||||||
|
Kontekst:
|
||||||
|
- Waluta: {currency}
|
||||||
|
- Zakres: {from_date} → {to_date}
|
||||||
|
- Cel: {goal}
|
||||||
|
- Poziom segmentacji: {axis}
|
||||||
|
|
||||||
|
Dane (CSV):
|
||||||
|
{csv_blocks}
|
||||||
|
|
||||||
|
Wskazówki:
|
||||||
|
- Użyj danych jak są (nie wymyślaj liczb).
|
||||||
|
- W meta.scope wpisz opis zakresu i segmentacji.
|
||||||
|
- Jeśli brak anomalii – anomalies: [].
|
||||||
|
- Kwoty do 2 miejsc, procenty do 1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ai = AIClient(AI_API_KEY)
|
||||||
|
result = ai.structured_analysis(prompt, schema)
|
||||||
|
|
||||||
|
out_dir = os.path.join(os.path.dirname(__file__), "out")
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
out_path = os.path.join(out_dir, f"{uuid.uuid4()}.json")
|
||||||
|
with open(out_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(result, f, ensure_ascii=False)
|
||||||
|
return {"status": "ok", "path": out_path}
|
||||||
|
|
||||||
|
def run_batch(from_date: str, to_date: str, axis: str, label: str):
|
||||||
|
# Zgodnie z blueprintem – generujemy linie NDJSON (skrót; pełny wariant w PDF)
|
||||||
|
# TODO: dodać realne wywołania batch_submit i zapisać ID/stan
|
||||||
|
raise NotImplementedError("Implement batch per blueprint")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
sub = p.add_subparsers(dest="cmd")
|
||||||
|
o = sub.add_parser("online")
|
||||||
|
o.add_argument("from_date"); o.add_argument("to_date"); o.add_argument("currency")
|
||||||
|
o.add_argument("axis", choices=["sku_id","client_id","region_code"])
|
||||||
|
o.add_argument("label"); o.add_argument("top_n", type=int, nargs="?", default=50)
|
||||||
|
o.add_argument("goal")
|
||||||
|
b = sub.add_parser("batch")
|
||||||
|
b.add_argument("from_date"); b.add_argument("to_date"); b.add_argument("axis"); b.add_argument("label")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
if args.cmd == "online":
|
||||||
|
print(run_online(args.from_date, args.to_date, args.currency, args.axis, args.label, args.top_n, args.goal))
|
||||||
|
elif args.cmd == "batch":
|
||||||
|
print(run_batch(args.from_date, args.to_date, args.axis, args.label))
|
||||||
|
else:
|
||||||
|
p.print_help()
|
||||||
164
modules/EcmInvoiceOuts/bimit_invoiceSummary.php
Normal file
164
modules/EcmInvoiceOuts/bimit_invoiceSummary.php
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<!-- KPI -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Łączny przychód</div>
|
||||||
|
<div class="kpi-value">587 679,40 PLN</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Liczba faktur</div>
|
||||||
|
<div class="kpi-value">320</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">Sprzedane jednostki</div>
|
||||||
|
<div class="kpi-value">182 619 szt.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="kpi-label">AOV — średnia wartość faktury</div>
|
||||||
|
<div class="kpi-value">1 836,50 PLN</div>
|
||||||
|
<div class="kpi-sub muted">AOV = przychód / liczba faktur</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top produkty -->
|
||||||
|
<h2>Top produkty wg przychodu</h2>
|
||||||
|
<div class="card table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:22%">Kod</th>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th style="width:18%">Przychód [PLN]</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>FR00099_250_Wilfa</td><td>WIUCC-250 CLEANING LIQUID COFFEEMAKER, 250 ml</td><td>51 217,92</td></tr>
|
||||||
|
<tr><td>AGDPR01</td><td>Środek do czyszczenia pralek automatycznych</td><td>47 500,00</td></tr>
|
||||||
|
<tr><td>FR00013_1000_Drekker</td><td>Odkamieniacz do automatycznych ekspresów do kawy, 1000 ml</td><td>30 600,00</td></tr>
|
||||||
|
<tr><td>AGDCHRM01</td><td>Płyn do robotów mopujących, 500ml</td><td>22 277,70</td></tr>
|
||||||
|
<tr><td>FR00016_10_2g_amz_de</td><td>Cleaning tablets for coffee machines, 10 x 2g</td><td>19 426,00</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top klienci -->
|
||||||
|
<h2>Top klienci wg przychodu</h2>
|
||||||
|
<div class="card table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Klient</th>
|
||||||
|
<th style="width:20%">Przychód [PLN]</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Euro-net Sp. z o.o.</td><td>138 660,08</td></tr>
|
||||||
|
<tr><td>Wilfa AS</td><td>71 616,72</td></tr>
|
||||||
|
<tr><td>Aqualogis Polska Sp. z o.o.</td><td>58 108,20</td></tr>
|
||||||
|
<tr><td>dm-drogerie markt Sp. z o.o.</td><td>40 108,08</td></tr>
|
||||||
|
<tr><td>MediaRange GmbH</td><td>40 064,24</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mix change alerts -->
|
||||||
|
<h2>Alerty „mix change” (duże zmiany udziału produktu w przychodzie)</h2>
|
||||||
|
<div class="card table-card">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:11%">Data</th>
|
||||||
|
<th style="width:20%">Kod</th>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th style="width:12%">Przychód [PLN]</th>
|
||||||
|
<th style="width:10%">Szt.</th>
|
||||||
|
<th style="width:10%">Udział dnia</th>
|
||||||
|
<th style="width:14%">Porównanie</th>
|
||||||
|
<th style="width:13%">Δ udziału</th>
|
||||||
|
<th style="width:10%">Baseline</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-01</td>
|
||||||
|
<td>FR00006_250_amz_de</td>
|
||||||
|
<td>Płyn do czyszczenia pralek automatycznych, 250 ml</td>
|
||||||
|
<td>1 169,60</td>
|
||||||
|
<td>136</td>
|
||||||
|
<td><span class="pill ok">23,98%</span></td>
|
||||||
|
<td>vs mediana miesiąca</td>
|
||||||
|
<td><span class="pill ok">+19,99 pp</span></td>
|
||||||
|
<td>3,99%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-02</td>
|
||||||
|
<td>SECO002</td>
|
||||||
|
<td>ECO Wilgotne Ściereczki do ekranów</td>
|
||||||
|
<td>1 826,52</td>
|
||||||
|
<td>372</td>
|
||||||
|
<td><span class="pill ok">24,93%</span></td>
|
||||||
|
<td>vs mediana miesiąca</td>
|
||||||
|
<td><span class="pill ok">+12,17 pp</span></td>
|
||||||
|
<td>12,75%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-02</td>
|
||||||
|
<td>RE00094</td>
|
||||||
|
<td>Płyn do ekranów TABLET/SMARTFON/LCD/PLASMA, 250 ml</td>
|
||||||
|
<td>1 048,56</td>
|
||||||
|
<td>204</td>
|
||||||
|
<td><span class="pill warn">14,31%</span></td>
|
||||||
|
<td>vs mediana miesiąca</td>
|
||||||
|
<td><span class="pill warn">+6,55 pp</span></td>
|
||||||
|
<td>7,76%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-09</td>
|
||||||
|
<td>ICL-6550-INT</td>
|
||||||
|
<td>Compressed gas duster, 400 ml</td>
|
||||||
|
<td>3 494,40</td>
|
||||||
|
<td>624</td>
|
||||||
|
<td><span class="pill warn">10,09%</span></td>
|
||||||
|
<td>vs mediana miesiąca</td>
|
||||||
|
<td><span class="pill warn">+6,87 pp</span></td>
|
||||||
|
<td>3,23%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-09</td>
|
||||||
|
<td>ICL-6575-INT</td>
|
||||||
|
<td>Compressed gas duster, 600 ml</td>
|
||||||
|
<td>3 463,20</td>
|
||||||
|
<td>444</td>
|
||||||
|
<td><span class="pill">10,00%</span></td>
|
||||||
|
<td>vs mediana miesiąca</td>
|
||||||
|
<td><span class="pill">+4,39 pp</span></td>
|
||||||
|
<td>5,61%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-10</td>
|
||||||
|
<td>ICL-6550-INT</td>
|
||||||
|
<td>Compressed gas duster, 400 ml</td>
|
||||||
|
<td>1 881,60</td>
|
||||||
|
<td>336</td>
|
||||||
|
<td><span class="pill">3,05%</span></td>
|
||||||
|
<td>vs rolling 7 dni</td>
|
||||||
|
<td><span class="pill bad">−7,05 pp</span></td>
|
||||||
|
<td>10,09%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-10</td>
|
||||||
|
<td>ICL-6575-INT</td>
|
||||||
|
<td>Compressed gas duster, 600 ml</td>
|
||||||
|
<td>3 463,20</td>
|
||||||
|
<td>444</td>
|
||||||
|
<td><span class="pill">5,61%</span></td>
|
||||||
|
<td>vs rolling 7 dni</td>
|
||||||
|
<td><span class="pill bad">−4,39 pp</span></td>
|
||||||
|
<td>10,00%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2025-07-17</td>
|
||||||
|
<td>FR00006_250_amz_de</td>
|
||||||
|
<td>Płyn do czyszczenia pralek automatycznych, 250 ml</td>
|
||||||
|
<td>1 080,00</td>
|
||||||
|
<td>144</td>
|
||||||
|
<td><span class="pill">3
|
||||||
147
modules/EcmInvoiceOuts/invoice_ai_analysis.py
Normal file
147
modules/EcmInvoiceOuts/invoice_ai_analysis.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Invoice AI Analysis — end-to-end script (MySQL -> KPIs -> Mix Change -> Anomalies -> HTML)
|
||||||
|
See previous instructions for usage and requirements.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, List, Dict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
database: str
|
||||||
|
date_from: str
|
||||||
|
date_to: str
|
||||||
|
doc_type: str
|
||||||
|
output_html: str
|
||||||
|
|
||||||
|
def parse_args() -> Config:
|
||||||
|
parser = argparse.ArgumentParser(description="Invoice AI Analysis (MySQL -> HTML)")
|
||||||
|
parser.add_argument("--host", default=os.getenv("DB_HOST", "localhost"))
|
||||||
|
parser.add_argument("--port", default=int(os.getenv("DB_PORT", "3306")), type=int)
|
||||||
|
parser.add_argument("--user", default=os.getenv("DB_USER", "root"))
|
||||||
|
parser.add_argument("--password", default=os.getenv("DB_PASS", "rootpassword"))
|
||||||
|
parser.add_argument("--database", default=os.getenv("DB_NAME", "twinpol-mysql56"))
|
||||||
|
parser.add_argument("--from", dest="date_from", default="2025-07-01")
|
||||||
|
parser.add_argument("--to", dest="date_to", default="2025-08-01")
|
||||||
|
parser.add_argument("--type", dest="doc_type", default="normal")
|
||||||
|
parser.add_argument("--out", dest="output_html", default="report.html")
|
||||||
|
args = parser.parse_args()
|
||||||
|
return Config(
|
||||||
|
host=args.host, port=args.port, user=args.user, password=args.password,
|
||||||
|
database=args.database, date_from=args.date_from, date_to=args.date_to,
|
||||||
|
doc_type=args.doc_type, output_html=args.output_html
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_engine(cfg: Config):
|
||||||
|
url = f"mysql+pymysql://{cfg.user}:{cfg.password}@{cfg.host}:{cfg.port}/{cfg.database}?charset=utf8mb4"
|
||||||
|
return create_engine(url, pool_recycle=3600, pool_pre_ping=True, future=True)
|
||||||
|
|
||||||
|
def fetch_invoices(engine, cfg: Config) -> pd.DataFrame:
|
||||||
|
sql = text("""
|
||||||
|
SELECT i.document_no,
|
||||||
|
i.parent_name,
|
||||||
|
DATE(i.register_date) AS register_date,
|
||||||
|
ii.code,
|
||||||
|
ii.name,
|
||||||
|
ii.quantity,
|
||||||
|
ii.total_netto
|
||||||
|
FROM ecminvoiceoutitems AS ii
|
||||||
|
JOIN ecminvoiceouts AS i ON i.id = ii.ecminvoiceout_id
|
||||||
|
WHERE i.register_date >= :date_from
|
||||||
|
AND i.register_date < :date_to
|
||||||
|
AND i.type = :doc_type
|
||||||
|
""")
|
||||||
|
with engine.connect() as con:
|
||||||
|
df = pd.read_sql(sql, con, params={
|
||||||
|
"date_from": cfg.date_from,
|
||||||
|
"date_to": cfg.date_to,
|
||||||
|
"doc_type": cfg.doc_type
|
||||||
|
})
|
||||||
|
df["register_date"] = pd.to_datetime(df["register_date"], errors="coerce")
|
||||||
|
df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
|
||||||
|
df["total_netto"] = pd.to_numeric(df["total_netto"], errors="coerce")
|
||||||
|
return df.dropna(subset=["register_date", "quantity", "total_netto"])
|
||||||
|
|
||||||
|
def compute_kpis(df: pd.DataFrame) -> Dict[str, Any]:
|
||||||
|
total_revenue = float(df["total_netto"].sum())
|
||||||
|
total_invoices = int(df["document_no"].nunique())
|
||||||
|
total_units = float(df["quantity"].sum())
|
||||||
|
aov = float(total_revenue / total_invoices) if total_invoices else 0.0
|
||||||
|
top_products = (df.groupby(["code", "name"], as_index=False)
|
||||||
|
.agg(total_netto=("total_netto", "sum"))
|
||||||
|
.sort_values("total_netto", ascending=False)
|
||||||
|
.head(5))
|
||||||
|
top_customers = (df.groupby(["parent_name"], as_index=False)
|
||||||
|
.agg(total_netto=("total_netto", "sum"))
|
||||||
|
.sort_values("total_netto", ascending=False)
|
||||||
|
.head(5))
|
||||||
|
return {
|
||||||
|
"total_revenue": total_revenue,
|
||||||
|
"total_invoices": total_invoices,
|
||||||
|
"total_units": total_units,
|
||||||
|
"aov": aov,
|
||||||
|
"top_products": top_products,
|
||||||
|
"top_customers": top_customers,
|
||||||
|
}
|
||||||
|
|
||||||
|
def render_html(cfg: Config, kpis: Dict[str, Any]) -> str:
|
||||||
|
def fmt_cur(x): return f"{x:,.2f}".replace(",", " ").replace(".", ",")
|
||||||
|
def table(headers, rows):
|
||||||
|
th = "".join(f"<th>{h}</th>" for h in headers)
|
||||||
|
trs = "".join("<tr>" + "".join(f"<td>{v}</td>" for v in row) + "</tr>" for row in rows)
|
||||||
|
return f"<table><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table>"
|
||||||
|
kpi_table = table(["Metryka", "Wartość"], [
|
||||||
|
["Łączny przychód", f"{fmt_cur(kpis['total_revenue'])} PLN"],
|
||||||
|
["Liczba faktur", f"{kpis['total_invoices']}"],
|
||||||
|
["Sprzedane jednostki", f"{int(kpis['total_units']):,}".replace(",", " ")],
|
||||||
|
["Średnia wartość faktury", f"{fmt_cur(kpis['aov'])} PLN"]
|
||||||
|
])
|
||||||
|
prod_table = table(["Kod", "Produkt", "Przychód"], [
|
||||||
|
[r["code"], r["name"], fmt_cur(r["total_netto"]) + " PLN"]
|
||||||
|
for _, r in kpis["top_products"].iterrows()
|
||||||
|
])
|
||||||
|
cust_table = table(["Klient", "Przychód"], [
|
||||||
|
[r["parent_name"], fmt_cur(r["total_netto"]) + " PLN"]
|
||||||
|
for _, r in kpis["top_customers"].iterrows()
|
||||||
|
])
|
||||||
|
return f"""<html><head><meta charset="utf-8"><style>
|
||||||
|
body{{font-family:Arial, sans-serif;margin:20px}}table{{border-collapse:collapse;width:100%}}
|
||||||
|
th,td{{border:1px solid #ccc;padding:8px;text-align:left}}
|
||||||
|
th{{background:#f4f4f4}}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Analiza faktur ({cfg.date_from} → {cfg.date_to})</h1>
|
||||||
|
<h2>KPI</h2>{kpi_table}
|
||||||
|
<h2>Top produkty</h2>{prod_table}
|
||||||
|
<h2>Top klienci</h2>{cust_table}
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = parse_args()
|
||||||
|
engine = get_engine(cfg)
|
||||||
|
df = fetch_invoices(engine, cfg)
|
||||||
|
if df.empty:
|
||||||
|
print("No data found.")
|
||||||
|
return
|
||||||
|
kpis = compute_kpis(df)
|
||||||
|
html = render_html(cfg, kpis)
|
||||||
|
with open(cfg.output_html, "w", encoding="utf-8") as f:
|
||||||
|
f.write(html)
|
||||||
|
print(f"Report written to {cfg.output_html}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
26
modules/EcmInvoiceOuts/test.php
Executable file → Normal file
26
modules/EcmInvoiceOuts/test.php
Executable file → Normal file
@@ -1,18 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// Runs the Python script, waits for completion, and returns its output.
|
||||||
|
|
||||||
|
$cmd = 'python3 /var/www/html/modules/EcmInvoiceOuts/ai/test.py';
|
||||||
|
$output = [];
|
||||||
|
$returnVar = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $returnVar);
|
||||||
|
|
||||||
|
if ($returnVar !== 0) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo "Error running Python script:\n" . implode("\n", $output);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$url='http://damznac.pl/login'; // Specify your url
|
// Expect a single line with the count
|
||||||
$data= array('username'=>'lol','api_key'=>'val'); // Add parameters in key value
|
echo trim(implode("\n", $output));
|
||||||
$ch = curl_init(); // Initialize cURL
|
|
||||||
curl_setopt($ch, CURLOPT_URL,$url);
|
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
|
|
||||||
|
|
||||||
//curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
|
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
||||||
$return= curl_exec($ch);
|
|
||||||
var_dump($return);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
|
|
||||||
?>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user