Absatzmengen: nach Warenausgang (Versanddatum) oder nach Rechnung auswertbar
- shopping_orders.shipped_at (+ Herkunft live/dhl/geschaetzt): gesetzt im Model, sobald der Versandstatus auf versendet wechselt, egal an welcher Stelle der Status gesetzt wird. sales:backfill-shipped-at traegt Altbestellungen aus dem DHL-Label nach oder schaetzt. - SalesVolumeService rechnet Absatzmengen je Produkt wahlweise nach Versand (inkl. externer/API-Bestellungen, Stornos im Monat des Stornobelegs) oder nach Rechnung. Sets werden fuer die zweite Liste in Einzelartikel aufgeloest. - Admin-Seite: Umschalter Versand/Rechnung, Datenqualitaet (Anteil geschaetzter Versanddaten), Export Produkte und Einzelartikel. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a21bebc932
commit
d01b9bca3a
7 changed files with 1139 additions and 432 deletions
128
app/Console/Commands/BackfillShippedAt.php
Normal file
128
app/Console/Commands/BackfillShippedAt.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Acme\Dhl\Models\DhlShipment;
|
||||
use App\Models\ShoppingOrder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Traegt das Versanddatum fuer Altbestellungen nach, damit die Absatzauswertung
|
||||
* "nach Versand" auch rueckwirkend funktioniert.
|
||||
*
|
||||
* Quellen, in dieser Reihenfolge:
|
||||
* 1. Datum des ersten ausgehenden DHL-Labels – exakt, verfuegbar ab 23.10.2025.
|
||||
* 2. `updated_at` der Bestellung – Schaetzung. Gegengeprueft an Bestellungen MIT
|
||||
* Label liegt sie in rund 93 % innerhalb von 24 Stunden und in rund 97 % im
|
||||
* richtigen Monat am tatsaechlichen Versanddatum.
|
||||
*
|
||||
* Die Quelle wird in `shipped_at_source` festgehalten, damit geschaetzte Monate
|
||||
* in der Auswertung als solche ausgewiesen werden koennen.
|
||||
*/
|
||||
class BackfillShippedAt extends Command
|
||||
{
|
||||
protected $signature = 'sales:backfill-shipped-at
|
||||
{--fix : Tatsaechlich schreiben (ohne Flag nur Vorschau)}
|
||||
{--since= : Nur Bestellungen ab diesem Datum (Y-m-d)}
|
||||
{--chunk=500 : Bestellungen je Verarbeitungsblock}';
|
||||
|
||||
protected $description = 'Traegt shipped_at fuer bereits versendete Altbestellungen nach (DHL-Label oder Schaetzung)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$fix = (bool) $this->option('fix');
|
||||
$since = $this->option('since');
|
||||
$chunk = max(50, (int) $this->option('chunk'));
|
||||
|
||||
$query = ShoppingOrder::query()
|
||||
->whereIn('shipped', ShoppingOrder::SHIPPED_STATES)
|
||||
->whereNull('shipped_at')
|
||||
->when($since, fn ($q) => $q->where('created_at', '>=', $since))
|
||||
->orderBy('id');
|
||||
|
||||
$total = (clone $query)->count();
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('Keine Bestellungen ohne Versanddatum gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Bestellungen ohne Versanddatum: {$total}");
|
||||
|
||||
$counts = [ShoppingOrder::SHIPPED_AT_SOURCE_DHL => 0, ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED => 0];
|
||||
$byMonth = [];
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
|
||||
$query->chunkById($chunk, function ($orders) use ($fix, &$counts, &$byMonth, $bar) {
|
||||
$labels = $this->getLabelDates($orders->pluck('id')->all());
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$shippedAt = $labels[$order->id] ?? null;
|
||||
$source = ShoppingOrder::SHIPPED_AT_SOURCE_DHL;
|
||||
|
||||
if ($shippedAt === null) {
|
||||
$shippedAt = $order->updated_at ?: $order->created_at;
|
||||
$source = ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED;
|
||||
}
|
||||
|
||||
if ($shippedAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts[$source]++;
|
||||
$month = $shippedAt->format('Y-m');
|
||||
$byMonth[$month][$source] = ($byMonth[$month][$source] ?? 0) + 1;
|
||||
|
||||
if ($fix) {
|
||||
ShoppingOrder::withoutTimestamps(fn () => ShoppingOrder::query()
|
||||
->whereKey($order->id)
|
||||
->update(['shipped_at' => $shippedAt, 'shipped_at_source' => $source]));
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
});
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
ksort($byMonth);
|
||||
$rows = [];
|
||||
foreach ($byMonth as $month => $sources) {
|
||||
$dhl = $sources[ShoppingOrder::SHIPPED_AT_SOURCE_DHL] ?? 0;
|
||||
$estimated = $sources[ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED] ?? 0;
|
||||
$sum = $dhl + $estimated;
|
||||
$rows[] = [$month, $dhl, $estimated, $sum > 0 ? round($estimated / $sum * 100, 1).' %' : '-'];
|
||||
}
|
||||
|
||||
$this->table(['Versandmonat', 'aus DHL-Label', 'geschaetzt', 'Anteil geschaetzt'], $rows);
|
||||
$this->info('Exakt aus DHL-Label: '.$counts[ShoppingOrder::SHIPPED_AT_SOURCE_DHL]);
|
||||
$this->info('Geschaetzt aus updated_at: '.$counts[ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED]);
|
||||
|
||||
if (! $fix) {
|
||||
$this->warn('Trockenlauf! Nutze --fix um die Werte zu schreiben.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datum des ersten ausgehenden DHL-Labels je Bestellung.
|
||||
*
|
||||
* @param array<int, int> $orderIds
|
||||
* @return array<int, Carbon>
|
||||
*/
|
||||
private function getLabelDates(array $orderIds): array
|
||||
{
|
||||
return DhlShipment::query()
|
||||
->whereIn('order_id', $orderIds)
|
||||
->where('type', 'outbound')
|
||||
->orderBy('created_at')
|
||||
->get(['order_id', 'created_at'])
|
||||
->groupBy('order_id')
|
||||
->map(fn ($shipments) => $shipments->first()->created_at)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,16 +5,21 @@ namespace App\Http\Controllers\Admin;
|
|||
use App\Exports\UserTeamExport;
|
||||
use App\Exports\xExport;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Services\HTMLHelper;
|
||||
use Carbon\Carbon;
|
||||
use App\Services\SalesVolumeService;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* Absatzmengen je Produkt – wahlweise nach Versand (Warenausgang) oder nach
|
||||
* Rechnungseingang (Zahlung). Die eigentliche Ermittlung liegt in
|
||||
* {@see SalesVolumeService}, hier stehen nur Filter, Datatables und Exporte.
|
||||
*/
|
||||
class PaymentSalesController extends Controller
|
||||
{
|
||||
/** Zweite Liste der Seite: Sets/Kits in ihre Anlagen aufgeloest. */
|
||||
private const LIST_ARTICLES = 'articles';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('admin');
|
||||
|
|
@ -22,46 +27,153 @@ class PaymentSalesController extends Controller
|
|||
|
||||
public function index()
|
||||
{
|
||||
$this->setFilterVars();
|
||||
$service = $this->makeService();
|
||||
|
||||
$data = [
|
||||
'filter_months' => HTMLHelper::getTransMonths(),
|
||||
'filter_years' => HTMLHelper::getYearRange(2022),
|
||||
'filter_basis' => SalesVolumeService::basisOptions(),
|
||||
'basis' => $service->getBasis(),
|
||||
'basis_label' => $service->getBasisLabel(),
|
||||
'quality' => $service->getQualityStats(),
|
||||
];
|
||||
|
||||
return view('admin.payment.salesvolume', $data);
|
||||
}
|
||||
|
||||
public function datatable()
|
||||
{
|
||||
$service = $this->makeService();
|
||||
|
||||
$objects = Request::get('list') === self::LIST_ARTICLES
|
||||
? $service->getArticleVolumes()
|
||||
: $service->getProductVolumes();
|
||||
|
||||
$collection = collect();
|
||||
|
||||
foreach ($objects as $key => $obj) {
|
||||
$collection->push([
|
||||
'id' => $obj['product_id'] ?: $key,
|
||||
'name' => $obj['name'],
|
||||
'number' => $obj['number'],
|
||||
'value' => $obj['value'],
|
||||
]);
|
||||
}
|
||||
|
||||
return \DataTables::of($collection)->toJson();
|
||||
}
|
||||
|
||||
public function download()
|
||||
{
|
||||
|
||||
/*
|
||||
EXCEL EXPORT function */
|
||||
|
||||
/*
|
||||
if(Request::get('action') === "exportfull_paid"){
|
||||
return $this->exportFullList(1);
|
||||
}
|
||||
if(Request::get('action') === "exportfull_unpaid"){
|
||||
return $this->exportFullList(0);
|
||||
}
|
||||
*/
|
||||
if (Request::get('action') === 'exportfull_paid_invoice') {
|
||||
return $this->exportFullListInvoice();
|
||||
return $this->exportFullList();
|
||||
}
|
||||
if (Request::get('action') === 'export_articles') {
|
||||
return $this->exportKompaktList(self::LIST_ARTICLES);
|
||||
}
|
||||
if (Request::get('action') === 'export') {
|
||||
return $this->exportKompaktListInvoice();
|
||||
return $this->exportKompaktList();
|
||||
}
|
||||
}
|
||||
|
||||
private function setFilterVars()
|
||||
private function exportKompaktList(?string $list = null)
|
||||
{
|
||||
$service = $this->makeService();
|
||||
$objects = $list === self::LIST_ARTICLES
|
||||
? $service->getArticleVolumes()
|
||||
: $service->getProductVolumes();
|
||||
|
||||
$columns = [];
|
||||
$headers = ['#', 'Produkt', 'Artikelnummer', 'Menge'];
|
||||
|
||||
foreach ($objects as $key => $obj) {
|
||||
$columns[] = [
|
||||
'id' => $obj['product_id'] ?: $key,
|
||||
'name' => $obj['name'],
|
||||
'number' => $obj['number'],
|
||||
'value' => $obj['value'],
|
||||
];
|
||||
}
|
||||
|
||||
$filename = 'mivita-absatzmengen-'.$this->getBasisSlug().'-'
|
||||
.($list === self::LIST_ARTICLES ? 'einzelartikel' : 'kompakt').'-'
|
||||
.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
|
||||
|
||||
return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xls');
|
||||
}
|
||||
|
||||
private function exportFullList()
|
||||
{
|
||||
$service = $this->makeService();
|
||||
$isShipping = $service->getBasis() === SalesVolumeService::BASIS_SHIPPING;
|
||||
|
||||
$headers = [
|
||||
$isShipping ? 'Bestellung' : 'Rechnungsnummer',
|
||||
$isShipping ? 'Versanddatum' : 'Rechnungsdatum',
|
||||
'EMail',
|
||||
'Zahlung',
|
||||
'ProduktNummer',
|
||||
'ProduktName',
|
||||
'Anzahl',
|
||||
'Summe',
|
||||
'Hinweis',
|
||||
];
|
||||
|
||||
$columns = [];
|
||||
$total = [];
|
||||
|
||||
foreach ($service->getRows() as $row) {
|
||||
$key = $row['product_id'] ?: $row['number'];
|
||||
$total[$key] = ($total[$key] ?? 0) + $row['qty'];
|
||||
|
||||
$columns[] = [
|
||||
'ref' => $row['ref'],
|
||||
'date' => $row['date'],
|
||||
'email' => $row['email'],
|
||||
'payment' => $row['payment_type'],
|
||||
'number' => $row['number'],
|
||||
'name' => $row['name'],
|
||||
'qty' => $row['qty'],
|
||||
'sum' => $total[$key],
|
||||
'note' => $row['note'],
|
||||
];
|
||||
}
|
||||
|
||||
$filename = 'mivita-absatzmengen-'.$this->getBasisSlug().'-voll-'
|
||||
.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
|
||||
|
||||
return Excel::download(new xExport($columns, $headers), $filename.'.xls');
|
||||
}
|
||||
|
||||
private function makeService(): SalesVolumeService
|
||||
{
|
||||
$this->setFilterVars();
|
||||
|
||||
return new SalesVolumeService(
|
||||
(int) session('payment_sales_vol_filter_month'),
|
||||
(int) session('payment_sales_vol_filter_year'),
|
||||
(string) session('payment_sales_vol_filter_basis'),
|
||||
);
|
||||
}
|
||||
|
||||
private function getBasisSlug(): string
|
||||
{
|
||||
return session('payment_sales_vol_filter_basis') === SalesVolumeService::BASIS_INVOICE
|
||||
? 'rechnung'
|
||||
: 'versand';
|
||||
}
|
||||
|
||||
private function setFilterVars(): void
|
||||
{
|
||||
if (! session('payment_sales_vol_filter_month')) {
|
||||
session(['payment_sales_vol_filter_month' => intval(date('m'))]);
|
||||
}
|
||||
if (! session('payment_sales_vol_filter_year')) {
|
||||
session(['payment_sales_vol_filter_year' => intval(date('Y'))]);
|
||||
}
|
||||
if (! session('payment_sales_vol_filter_basis')) {
|
||||
session(['payment_sales_vol_filter_basis' => SalesVolumeService::BASIS_SHIPPING]);
|
||||
}
|
||||
|
||||
if (Request::get('payment_sales_vol_filter_month')) {
|
||||
session(['payment_sales_vol_filter_month' => Request::get('payment_sales_vol_filter_month')]);
|
||||
|
|
@ -69,396 +181,8 @@ class PaymentSalesController extends Controller
|
|||
if (Request::get('payment_sales_vol_filter_year')) {
|
||||
session(['payment_sales_vol_filter_year' => Request::get('payment_sales_vol_filter_year')]);
|
||||
}
|
||||
if (Request::get('payment_sales_vol_filter_basis')) {
|
||||
session(['payment_sales_vol_filter_basis' => Request::get('payment_sales_vol_filter_basis')]);
|
||||
}
|
||||
}
|
||||
|
||||
private function exportKompaktListInvoice()
|
||||
{
|
||||
$objects = $this->initKompaktList();
|
||||
$columns = [];
|
||||
$filename = 'mivita-absatzmengen-kompakt'.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
|
||||
$headers = [
|
||||
'#',
|
||||
'Produkt',
|
||||
'Artikelnummer',
|
||||
'Menge',
|
||||
|
||||
];
|
||||
if ($objects) {
|
||||
foreach ($objects as $key => $obj) {
|
||||
$columns[] = [
|
||||
'id' => $key,
|
||||
'name' => $obj['name'],
|
||||
'number' => $obj['number'],
|
||||
'value' => $obj['value'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return Excel::download(new UserTeamExport($columns, $headers), $filename.'.xls');
|
||||
}
|
||||
|
||||
private function exportFullListInvoice()
|
||||
{
|
||||
|
||||
$this->setFilterVars();
|
||||
|
||||
$UserInvoices = UserInvoice::with('shopping_order')->with('shopping_order.shopping_user')->select('user_invoices.*')
|
||||
->where('user_invoices.month', '=', session('payment_sales_vol_filter_month'))
|
||||
->where('user_invoices.year', '=', session('payment_sales_vol_filter_year'))
|
||||
->get();
|
||||
|
||||
$headers = ['Rechnungsnummer', 'Datum', 'EMail', 'Zahlung', 'ProduktNummer', 'ProduktName', 'Anzahl', 'Summe', 'Kompensation'];
|
||||
$columns = [];
|
||||
$hasSOID = [];
|
||||
$total_value = [];
|
||||
|
||||
foreach ($UserInvoices as $UserInvoice) {
|
||||
if ($UserInvoice->shopping_order) {
|
||||
$ShoppingOrder = $UserInvoice->shopping_order;
|
||||
$object = [];
|
||||
$object['Rechnungsnummer'] = $UserInvoice->full_number;
|
||||
$object['Datum'] = $UserInvoice->date;
|
||||
$object['EMail'] = $ShoppingOrder->shopping_user ? $ShoppingOrder->shopping_user->billing_email : 'n/a';
|
||||
$object['Zahlung'] = $ShoppingOrder->getPaymentForType();
|
||||
|
||||
if ($ShoppingOrder->payment_for === 5) { // homeparty
|
||||
if ($ShoppingOrder->homeparty) {
|
||||
foreach ($ShoppingOrder->homeparty->homeparty_order_items as $homeparty_item) {
|
||||
$total_value[$homeparty_item->product_id] = isset($total_value[$homeparty_item->product_id]) ? $total_value[$homeparty_item->product_id] + $homeparty_item->qty : $homeparty_item->qty;
|
||||
$object['ProduktNummer'] = $homeparty_item->product ? $homeparty_item->product->number : 'n/a';
|
||||
$object['ProduktName'] = $homeparty_item->product ? $homeparty_item->product->name : 'n/a';
|
||||
$object['Anzahl'] = $homeparty_item->qty;
|
||||
$object['Summe'] = $total_value[$homeparty_item->product_id];
|
||||
$object['Kompensation'] = '';
|
||||
$columns[] = $object;
|
||||
}
|
||||
}
|
||||
|
||||
} elseif ($ShoppingOrder->payment_for === 8) { // collective_invoice
|
||||
if ($ShoppingOrder->shopping_collect_order) {
|
||||
foreach ($ShoppingOrder->shopping_collect_order->shop_items as $shop_item) {
|
||||
$total_value[$shop_item['pid']] = isset($total_value[$shop_item['pid']]) ? $total_value[$shop_item['pid']] + $shop_item['qty'] : $shop_item['qty'];
|
||||
$object['ProduktNummer'] = $shop_item['number'];
|
||||
$object['ProduktName'] = $shop_item['name'];
|
||||
$object['Anzahl'] = $shop_item['qty'];
|
||||
$object['Summe'] = $total_value[$shop_item['pid']];
|
||||
$object['Kompensation'] = '';
|
||||
$columns[] = $object;
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
if ($ShoppingOrder->shopping_order_items) {
|
||||
foreach ($ShoppingOrder->shopping_order_items as $shopping_order_item) {
|
||||
$total_value[$shopping_order_item->product_id] = isset($total_value[$shopping_order_item->product_id]) ? $total_value[$shopping_order_item->product_id] + $shopping_order_item->qty : $shopping_order_item->qty;
|
||||
$object['ProduktNummer'] = $shopping_order_item->product ? $shopping_order_item->product->number : 'n/a';
|
||||
$object['ProduktName'] = $shopping_order_item->product ? $shopping_order_item->product->name : 'n/a';
|
||||
$object['Anzahl'] = $shopping_order_item->qty;
|
||||
$object['Summe'] = $total_value[$shopping_order_item->product_id];
|
||||
$object['Kompensation'] = ($shopping_order_item->comp ? $shopping_order_item->comp : '');
|
||||
|
||||
$columns[] = $object;
|
||||
}
|
||||
}
|
||||
}
|
||||
$hasSOID[] = $ShoppingOrder->id;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$filename = 'mivita-absatzmengen-voll-'.session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year').'-export';
|
||||
|
||||
return Excel::download(new xExport($columns, $headers), $filename.'.xls');
|
||||
|
||||
}
|
||||
|
||||
private function initKompaktList()
|
||||
{
|
||||
$this->setFilterVars();
|
||||
|
||||
$UserInvoices = UserInvoice::with('shopping_order')->with('shopping_order.shopping_user')->select('user_invoices.*')
|
||||
->where('user_invoices.month', '=', session('payment_sales_vol_filter_month'))
|
||||
->where('user_invoices.year', '=', session('payment_sales_vol_filter_year'))
|
||||
->get();
|
||||
|
||||
$objects = [];
|
||||
|
||||
foreach ($UserInvoices as $UserInvoice) {
|
||||
if ($UserInvoice->shopping_order) {
|
||||
$ShoppingOrder = $UserInvoice->shopping_order;
|
||||
|
||||
if ($ShoppingOrder->payment_for === 5) { // homeparty
|
||||
if ($ShoppingOrder->homeparty) {
|
||||
foreach ($ShoppingOrder->homeparty->homeparty_order_items as $homeparty_item) {
|
||||
if (isset($objects[$homeparty_item->product_id])) {
|
||||
$value = intval($objects[$homeparty_item->product_id]['value'] + $homeparty_item->qty);
|
||||
$objects[$homeparty_item->product_id]['value'] = $value;
|
||||
} else {
|
||||
$objects[$homeparty_item->product_id] = [
|
||||
'name' => $homeparty_item->product ? $homeparty_item->product->name : 'n/a '.$homeparty_item->product_id,
|
||||
'number' => $homeparty_item->product ? $homeparty_item->product->number : 'n/a '.$homeparty_item->product_id,
|
||||
'value' => $homeparty_item->qty,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} elseif ($ShoppingOrder->payment_for === 8) { // collective_invoice
|
||||
if ($ShoppingOrder->shopping_collect_order) {
|
||||
foreach ($ShoppingOrder->shopping_collect_order->shop_items as $shop_item) {
|
||||
|
||||
if (isset($objects[$shop_item['pid']])) {
|
||||
$value = intval($objects[$shop_item['pid']]['value'] + $shop_item['qty']);
|
||||
$objects[$shop_item['pid']]['value'] = $value;
|
||||
} else {
|
||||
$objects[$shop_item['pid']] = [
|
||||
'name' => $shop_item['name'],
|
||||
'number' => $shop_item['number'],
|
||||
'value' => $shop_item['qty'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($ShoppingOrder->shopping_order_items) {
|
||||
foreach ($ShoppingOrder->shopping_order_items as $shopping_order_item) {
|
||||
if (isset($objects[$shopping_order_item->product_id])) {
|
||||
$value = intval($objects[$shopping_order_item->product_id]['value'] + $shopping_order_item->qty);
|
||||
$objects[$shopping_order_item->product_id]['value'] = $value;
|
||||
} else {
|
||||
$objects[$shopping_order_item->product_id] = [
|
||||
'name' => $shopping_order_item->product ? $shopping_order_item->product->name : 'n/a '.$shopping_order_item->product_id,
|
||||
'number' => $shopping_order_item->product ? $shopping_order_item->product->number : 'n/a '.$shopping_order_item->product_id,
|
||||
'value' => $shopping_order_item->qty,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$hasSOID[] = $ShoppingOrder->id;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return $objects;
|
||||
|
||||
}
|
||||
|
||||
public function datatable()
|
||||
{
|
||||
|
||||
/*$collect = collect([
|
||||
['id' => 1, 'name' => 'John', 'number'=>92012, 'value'=>123],
|
||||
['id' => 2, 'name' => 'Jane', 'number'=>92012, 'value'=>123],
|
||||
['id' => 3, 'name' => 'James', 'number'=>92012, 'value'=>123],
|
||||
]);*/
|
||||
$objects = $this->initKompaktList();
|
||||
$collection = collect();
|
||||
if ($objects) {
|
||||
foreach ($objects as $key => $obj) {
|
||||
$collection->push([
|
||||
'id' => $key,
|
||||
'name' => $obj['name'],
|
||||
'number' => $obj['number'],
|
||||
'value' => $obj['value'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return \DataTables::of($collection)->toJson();
|
||||
}
|
||||
|
||||
/*
|
||||
//Auswertung nach ShoppingOrder
|
||||
//nach Datum created_at wann die Bestellung erstellt wurde
|
||||
//Ist nicht das Datum der Rechnung, da hier teilweise die Sammelrechnungen oder Zahlungen erst in nächsten Monat erfolgen
|
||||
|
||||
|
||||
public function exportFullList($paid = 1){
|
||||
|
||||
|
||||
$date_start = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->format('Y-m-d H:i:s');
|
||||
$date_end = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->endOfMonth()->format('Y-m-d H:i:s');
|
||||
$ShoppingOrders = ShoppingOrder::where('paid', $paid)->where('mode', 'live')->whereBetween('created_at', [$date_start, $date_end])->get();
|
||||
|
||||
$txActions = ['prev' => 'keine Zahlung', 'appointed' => 'offen', 'failed' => 'abbruch', 'paid' => 'bezahlt'];
|
||||
$headers = array('ID', 'Zahlung', 'Datum', 'EMail', 'ProduktID', 'ProduktNummer', 'ProduktName', 'Anzahl', 'Notiz', 'Gesamt');
|
||||
$objects = [];
|
||||
$columns = [];
|
||||
$hasSOID = [];
|
||||
foreach($ShoppingOrders as $ShoppingOrder){
|
||||
foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){
|
||||
$value = "";
|
||||
if($shopping_order_item->product){
|
||||
if(isset($objects[$shopping_order_item->product->id])){
|
||||
$value = intval($objects[$shopping_order_item->product->id]['value'] + $shopping_order_item->qty);
|
||||
$objects[$shopping_order_item->product->id]['value'] = $value;
|
||||
}else{
|
||||
$objects[$shopping_order_item->product->id] = [
|
||||
'name' => $shopping_order_item->product->name,
|
||||
'number' => $shopping_order_item->product->number,
|
||||
'value' => $shopping_order_item->qty
|
||||
];
|
||||
$value = $shopping_order_item->qty;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
$object = [];
|
||||
if(in_array($ShoppingOrder->id, $hasSOID)){
|
||||
$object['ID'] = '';
|
||||
$object['EMail'] = '';
|
||||
$object['Zahlung'] = '';
|
||||
$object['Datum'] = '';
|
||||
}else{
|
||||
$object['ID'] = $ShoppingOrder->id;
|
||||
$object['EMail'] = $ShoppingOrder->shopping_user ? $ShoppingOrder->shopping_user->billing_email : 'n/a';
|
||||
$object['Zahlung'] = isset($txActions[$ShoppingOrder->txaction]) ? $txActions[$ShoppingOrder->txaction] : $ShoppingOrder->txaction;
|
||||
$object['Datum'] = $ShoppingOrder->created_at->format('d.m.Y');
|
||||
}
|
||||
$object['ProduktID'] = $shopping_order_item->product_id;
|
||||
$object['ProduktNummer'] = $shopping_order_item->product ? $shopping_order_item->product->number : "n/a";
|
||||
$object['ProduktName'] = $shopping_order_item->product ? $shopping_order_item->product->name : "n/a";
|
||||
$object['Anzahl'] = $shopping_order_item->qty;
|
||||
$object['Notiz'] = ($shopping_order_item->comp ? 'Compensation '.$shopping_order_item->comp : '') . ($shopping_order_item->shopping_collect_order_id ? 'Sammelbestellung '.$shopping_order_item->shopping_collect_order_id : '');
|
||||
$object['Gesamt'] = $value;
|
||||
$columns[] = $object;
|
||||
|
||||
$hasSOID[] = $ShoppingOrder->id;
|
||||
}
|
||||
}
|
||||
if($paid){
|
||||
$filename = "mivita-absatzmengen-full-paid-".session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year')."-export";
|
||||
}else{
|
||||
$filename = "mivita-absatzmengen-full-unpaid-".session('payment_sales_vol_filter_month').'_'.session('payment_sales_vol_filter_year')."-export";
|
||||
}
|
||||
|
||||
return Excel::download(new xExport($columns, $headers), $filename.'.xls');
|
||||
|
||||
//CSV EXPORT function
|
||||
$headers = array(
|
||||
"Content-type" => "text/csv",
|
||||
"Content-Disposition" => "attachment; filename=$fileName",
|
||||
"Pragma" => "no-cache",
|
||||
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
|
||||
"Expires" => "0"
|
||||
);
|
||||
|
||||
$header = array('ID', 'Zahlung', 'Datum', 'EMail', 'ProduktID', 'ProduktNummer', 'ProduktName', 'Anzahl', 'Notiz', 'Gesamt');
|
||||
|
||||
$callback = function() use($columns, $header) {
|
||||
|
||||
$file = fopen('php://output', 'w');
|
||||
fputcsv($file, $header);
|
||||
$row = [];
|
||||
|
||||
foreach ($columns as $row) {
|
||||
fputcsv($file, $row);
|
||||
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->stream($callback, 200, $headers);
|
||||
|
||||
|
||||
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
//alte Funktion auswerung nach ShoppingOrder
|
||||
private function testCheckFunction(){
|
||||
//$date_start = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->format('Y-m-d');
|
||||
//$date_end = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->endOfMonth()->format('Y-m-d');
|
||||
|
||||
$date_start = Carbon::parse('01.01.2024')->format('Y-m-d H:i:s');
|
||||
$date_end = Carbon::parse('01.01.2024')->endOfMonth()->format('Y-m-d H:i:s');
|
||||
dump($date_start);
|
||||
dump($date_end);
|
||||
|
||||
$ShoppingOrders = ShoppingOrder::where('mode', 'live')->whereBetween('created_at', [$date_start, $date_end])->get();
|
||||
|
||||
$objects = [];
|
||||
$counter = 0;
|
||||
foreach($ShoppingOrders as $ShoppingOrder){
|
||||
foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){
|
||||
|
||||
if($shopping_order_item->product){
|
||||
if($shopping_order_item->product->id === 122){
|
||||
//dump($shopping_order_item->qty);
|
||||
//$counter += $shopping_order_item->qty;
|
||||
if(isset($objects[$shopping_order_item->product->id])){
|
||||
$value = intval($objects[$shopping_order_item->product->id]['value'] + $shopping_order_item->qty);
|
||||
$objects[$shopping_order_item->product->id]['value'] = $value;
|
||||
}else{
|
||||
$objects[$shopping_order_item->product->id] = [
|
||||
'name' => $shopping_order_item->product->name,
|
||||
'number' => $shopping_order_item->product->number,
|
||||
'value' => $shopping_order_item->qty
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$ShoppingOrderItems = ShoppingOrderItem::whereProductId(122)->whereBetween('created_at', [$date_start, $date_end])->get();
|
||||
$counter = 0;
|
||||
foreach($ShoppingOrderItems as $ShoppingOrderItem){
|
||||
$counter += $ShoppingOrderItem->qty;
|
||||
dump($ShoppingOrderItem->id);
|
||||
}
|
||||
// dump($objects);
|
||||
dump($counter);
|
||||
dd("OKAY");
|
||||
}*/
|
||||
|
||||
/*
|
||||
// alte Funktion auswerung nach ShoppingOrder
|
||||
private function initSearch($returnColl = true)
|
||||
{
|
||||
$this->setFilterVars();
|
||||
|
||||
$date_start = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->format('Y-m-d H:i:s');
|
||||
$date_end = Carbon::parse('01.'.session('payment_sales_vol_filter_month').'.'.session('payment_sales_vol_filter_year'))->endOfMonth()->format('Y-m-d H:i:s');
|
||||
$ShoppingOrders = ShoppingOrder::where('paid', 1)->where('mode', 'live')->whereBetween('created_at', [$date_start, $date_end])->get();
|
||||
|
||||
$objects = [];
|
||||
foreach($ShoppingOrders as $ShoppingOrder){
|
||||
foreach($ShoppingOrder->shopping_order_items as $shopping_order_item){
|
||||
if($shopping_order_item->product){
|
||||
if(isset($objects[$shopping_order_item->product->id])){
|
||||
$value = intval($objects[$shopping_order_item->product->id]['value'] + $shopping_order_item->qty);
|
||||
$objects[$shopping_order_item->product->id]['value'] = $value;
|
||||
}else{
|
||||
$objects[$shopping_order_item->product->id] = [
|
||||
'name' => $shopping_order_item->product->name,
|
||||
'number' => $shopping_order_item->product->number,
|
||||
'value' => $shopping_order_item->qty
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($returnColl){
|
||||
$collection = collect();
|
||||
|
||||
foreach($objects as $key => $obj){
|
||||
$collection->push([
|
||||
'id' => $key,
|
||||
'name' => $obj['name'],
|
||||
'number' => $obj['number'],
|
||||
'value' => $obj['value'],
|
||||
]);
|
||||
}
|
||||
return $collection;
|
||||
}
|
||||
return $objects;
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ class ShoppingOrder extends Model
|
|||
|
||||
use SoftDeletes;
|
||||
|
||||
protected $dates = ['deleted_at'];
|
||||
protected $dates = ['deleted_at', 'shipped_at'];
|
||||
|
||||
protected $fillable = [
|
||||
'shopping_user_id',
|
||||
|
|
@ -177,6 +177,8 @@ class ShoppingOrder extends Model
|
|||
'customer_order_source_comment',
|
||||
'shipped',
|
||||
'tracking',
|
||||
'shipped_at',
|
||||
'shipped_at_source',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -187,6 +189,7 @@ class ShoppingOrder extends Model
|
|||
'is_abo' => 'bool',
|
||||
'recovery_user_abo_id' => 'int',
|
||||
'points' => 'float',
|
||||
'shipped_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const CUSTOMER_ORDER_SOURCE_OPTIONS = [
|
||||
|
|
@ -199,6 +202,49 @@ class ShoppingOrder extends Model
|
|||
'other' => 'Sonstiges',
|
||||
];
|
||||
|
||||
/**
|
||||
* Versandstatus, die als "ist rausgegangen" gelten ({@see self::$shippedTypes}).
|
||||
*/
|
||||
public const SHIPPED_STATES = [2, 3, 4];
|
||||
|
||||
public const SHIPPED_AT_SOURCE_LIVE = 'live';
|
||||
|
||||
public const SHIPPED_AT_SOURCE_DHL = 'dhl';
|
||||
|
||||
public const SHIPPED_AT_SOURCE_ESTIMATED = 'geschaetzt';
|
||||
|
||||
/**
|
||||
* Setzt den Versandzeitpunkt, sobald der Versandstatus auf "versendet" wechselt.
|
||||
* Der Hook sitzt bewusst im Model, weil der Status an mehreren Stellen gesetzt
|
||||
* wird (Admin-Liste, Abo-Fulfillment, API) und keine davon uebersehen werden darf.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $order): void {
|
||||
if ($order->shipped_at !== null) {
|
||||
return;
|
||||
}
|
||||
if (! in_array((int) $order->shipped, self::SHIPPED_STATES, true)) {
|
||||
return;
|
||||
}
|
||||
if ($order->exists && ! $order->isDirty('shipped')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order->shipped_at = now();
|
||||
$order->shipped_at_source = self::SHIPPED_AT_SOURCE_LIVE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestellungen, die im angegebenen Zeitraum versendet wurden.
|
||||
*/
|
||||
public function scopeShippedBetween(Builder $query, Carbon $from, Carbon $to): Builder
|
||||
{
|
||||
return $query->whereIn('shipped', self::SHIPPED_STATES)
|
||||
->whereBetween('shipped_at', [$from, $to]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
|
|
|||
425
app/Services/SalesVolumeService.php
Normal file
425
app/Services/SalesVolumeService.php
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ProductBundle;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserInvoice;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Ermittelt die Absatzmengen eines Monats auf zwei klar getrennten Grundlagen:
|
||||
*
|
||||
* - {@see self::BASIS_SHIPPING}: was im Monat tatsaechlich rausgegangen ist
|
||||
* (`shopping_orders.shipped_at`). Externe/API-Bestellungen zaehlen hier direkt,
|
||||
* die spaetere Sammelrechnung wird uebersprungen, damit nichts doppelt laeuft.
|
||||
* - {@see self::BASIS_INVOICE}: was im Monat fakturiert wurde
|
||||
* (`user_invoices.month/year`). Externe Bestellungen erscheinen hier erst ueber
|
||||
* die bezahlte Sammelrechnung des Partners.
|
||||
*
|
||||
* Stornorechnungen werden auf beiden Grundlagen abgezogen statt addiert.
|
||||
*
|
||||
* @phpstan-type VolumeRow array{ref: string, date: string, email: string, payment_type: string, product_id: int|null, number: string, name: string, qty: int, note: string}
|
||||
*/
|
||||
class SalesVolumeService
|
||||
{
|
||||
public const BASIS_SHIPPING = 'shipping';
|
||||
|
||||
public const BASIS_INVOICE = 'invoice';
|
||||
|
||||
/** Sammelrechnung des Partners – auf Versandbasis kein eigener Warenausgang. */
|
||||
private const PAYMENT_FOR_COLLECTIVE_INVOICE = 8;
|
||||
|
||||
private const PAYMENT_FOR_HOMEPARTY = 5;
|
||||
|
||||
/** @var array<int, VolumeRow>|null */
|
||||
private ?array $rows = null;
|
||||
|
||||
private Carbon $from;
|
||||
|
||||
private Carbon $to;
|
||||
|
||||
public function __construct(
|
||||
private int $month,
|
||||
private int $year,
|
||||
private string $basis = self::BASIS_SHIPPING,
|
||||
) {
|
||||
if (! array_key_exists($this->basis, self::basisOptions())) {
|
||||
$this->basis = self::BASIS_SHIPPING;
|
||||
}
|
||||
|
||||
$this->from = Carbon::create($this->year, $this->month, 1)->startOfMonth();
|
||||
$this->to = $this->from->copy()->endOfMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function basisOptions(): array
|
||||
{
|
||||
return [
|
||||
self::BASIS_SHIPPING => 'Absatz nach Versand (Warenausgang)',
|
||||
self::BASIS_INVOICE => 'Absatz nach Rechnungseingang (Zahlung)',
|
||||
];
|
||||
}
|
||||
|
||||
public function getBasis(): string
|
||||
{
|
||||
return $this->basis;
|
||||
}
|
||||
|
||||
public function getBasisLabel(): string
|
||||
{
|
||||
return self::basisOptions()[$this->basis];
|
||||
}
|
||||
|
||||
/**
|
||||
* Absatz je verkauftem Artikel – Sets bleiben eine eigene Zeile.
|
||||
*
|
||||
* @return array<int|string, array{product_id: int|null, name: string, number: string, value: int}>
|
||||
*/
|
||||
public function getProductVolumes(): array
|
||||
{
|
||||
$objects = [];
|
||||
|
||||
foreach ($this->getRows() as $row) {
|
||||
$key = $row['product_id'] ?: 'x-'.$row['number'];
|
||||
|
||||
if (isset($objects[$key])) {
|
||||
$objects[$key]['value'] += $row['qty'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$objects[$key] = [
|
||||
'product_id' => $row['product_id'],
|
||||
'name' => $row['name'],
|
||||
'number' => $row['number'],
|
||||
'value' => $row['qty'],
|
||||
];
|
||||
}
|
||||
|
||||
return $objects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absatz je Einzelartikel – Sets/Kits sind in ihre hinterlegten Anlagen aufgeloest.
|
||||
*
|
||||
* @return array<int|string, array{product_id: int|null, name: string, number: string, value: int}>
|
||||
*/
|
||||
public function getArticleVolumes(): array
|
||||
{
|
||||
$rows = $this->getRows();
|
||||
$bundles = $this->getBundleMap(array_filter(array_column($rows, 'product_id')));
|
||||
$objects = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$parts = $bundles[$row['product_id']] ?? null;
|
||||
|
||||
if (! $parts) {
|
||||
$parts = [[
|
||||
'product_id' => $row['product_id'],
|
||||
'number' => $row['number'],
|
||||
'name' => $row['name'],
|
||||
'quantity' => 1,
|
||||
]];
|
||||
}
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$key = $part['product_id'] ?: 'x-'.$part['number'];
|
||||
$qty = $row['qty'] * $part['quantity'];
|
||||
|
||||
if (isset($objects[$key])) {
|
||||
$objects[$key]['value'] += $qty;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$objects[$key] = [
|
||||
'product_id' => $part['product_id'],
|
||||
'name' => $part['name'],
|
||||
'number' => $part['number'],
|
||||
'value' => $qty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $objects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelpositionen fuer die vollstaendige Liste (Export).
|
||||
*
|
||||
* @return array<int, VolumeRow>
|
||||
*/
|
||||
public function getRows(): array
|
||||
{
|
||||
if ($this->rows === null) {
|
||||
$this->rows = $this->basis === self::BASIS_INVOICE
|
||||
? $this->collectInvoiceRows()
|
||||
: $this->collectShippingRows();
|
||||
}
|
||||
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datengrundlage des Monats: Anzahl Bestellungen und Anteil geschaetzter Versanddaten.
|
||||
*
|
||||
* @return array{orders: int, estimated: int, estimated_percent: float, without_date: int}
|
||||
*/
|
||||
public function getQualityStats(): array
|
||||
{
|
||||
if ($this->basis !== self::BASIS_SHIPPING) {
|
||||
return ['orders' => 0, 'estimated' => 0, 'estimated_percent' => 0.0, 'without_date' => 0];
|
||||
}
|
||||
|
||||
$orders = $this->shippedOrdersQuery()->count();
|
||||
$estimated = $this->shippedOrdersQuery()
|
||||
->where('shipped_at_source', ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED)
|
||||
->count();
|
||||
|
||||
$withoutDate = ShoppingOrder::query()
|
||||
->where('mode', 'live')
|
||||
->whereIn('shipped', ShoppingOrder::SHIPPED_STATES)
|
||||
->whereNull('shipped_at')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'orders' => $orders,
|
||||
'estimated' => $estimated,
|
||||
'estimated_percent' => $orders > 0 ? round($estimated / $orders * 100, 1) : 0.0,
|
||||
'without_date' => $withoutDate,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, VolumeRow>
|
||||
*/
|
||||
private function collectInvoiceRows(): array
|
||||
{
|
||||
$invoices = UserInvoice::query()
|
||||
->with($this->orderRelations('shopping_order.'))
|
||||
->with('shopping_order')
|
||||
->where('month', $this->month)
|
||||
->where('year', $this->year)
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$order = $invoice->shopping_order;
|
||||
|
||||
if (! $order) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isCancellation = $this->isCancellationInvoice($invoice);
|
||||
|
||||
$rows = array_merge($rows, $this->orderRows(
|
||||
$order,
|
||||
$isCancellation ? -1 : 1,
|
||||
(string) $invoice->full_number,
|
||||
$invoice->date ? Carbon::parse($invoice->date)->format('d.m.Y') : '',
|
||||
$isCancellation ? 'Storno' : ''
|
||||
));
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, VolumeRow>
|
||||
*/
|
||||
private function collectShippingRows(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->shippedOrdersQuery()->with($this->orderRelations())->get() as $order) {
|
||||
$rows = array_merge($rows, $this->orderRows(
|
||||
$order,
|
||||
1,
|
||||
(string) $order->id,
|
||||
$order->shipped_at ? $order->shipped_at->format('d.m.Y') : '',
|
||||
$order->shipped_at_source === ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED ? 'Versanddatum geschaetzt' : ''
|
||||
));
|
||||
}
|
||||
|
||||
return array_merge($rows, $this->collectReturnRows());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stornos/Retouren im Monat – aber nur fuer Bestellungen, die vorher auch
|
||||
* wirklich rausgegangen sind. Vor dem Versand stornierte Bestellungen wurden
|
||||
* auf Versandbasis nie gezaehlt und duerfen deshalb auch nicht abgezogen werden.
|
||||
*
|
||||
* @return array<int, VolumeRow>
|
||||
*/
|
||||
private function collectReturnRows(): array
|
||||
{
|
||||
$cancellations = UserInvoice::query()
|
||||
->with($this->orderRelations('shopping_order.'))
|
||||
->with('shopping_order')
|
||||
->where('cancellation', true)
|
||||
->whereNull('cancellation_id')
|
||||
->whereBetween('date', [$this->from->toDateString(), $this->to->toDateString()])
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($cancellations as $invoice) {
|
||||
$order = $invoice->shopping_order;
|
||||
|
||||
if (! $order || $order->shipped_at === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows = array_merge($rows, $this->orderRows(
|
||||
$order,
|
||||
-1,
|
||||
(string) $invoice->full_number,
|
||||
$invoice->date ? Carbon::parse($invoice->date)->format('d.m.Y') : '',
|
||||
'Storno / Retoure'
|
||||
));
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<ShoppingOrder>
|
||||
*/
|
||||
private function shippedOrdersQuery()
|
||||
{
|
||||
return ShoppingOrder::query()
|
||||
->where('mode', 'live')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('payment_for')
|
||||
->orWhere('payment_for', '!=', self::PAYMENT_FOR_COLLECTIVE_INVOICE);
|
||||
})
|
||||
->shippedBetween($this->from, $this->to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positionen einer Bestellung – je nach Bestellart aus drei verschiedenen Quellen.
|
||||
*
|
||||
* @return array<int, VolumeRow>
|
||||
*/
|
||||
private function orderRows(ShoppingOrder $order, int $sign, string $ref, string $date, string $note): array
|
||||
{
|
||||
$base = [
|
||||
'ref' => $ref,
|
||||
'date' => $date,
|
||||
'email' => $order->shopping_user ? (string) $order->shopping_user->billing_email : 'n/a',
|
||||
'payment_type' => $order->getPaymentForType(),
|
||||
'note' => $note,
|
||||
];
|
||||
|
||||
$rows = [];
|
||||
|
||||
if ((int) $order->payment_for === self::PAYMENT_FOR_HOMEPARTY) {
|
||||
if (! $order->homeparty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($order->homeparty->homeparty_order_items as $item) {
|
||||
$rows[] = $base + [
|
||||
'product_id' => $item->product_id ? (int) $item->product_id : null,
|
||||
'number' => $item->product ? (string) $item->product->number : 'n/a '.$item->product_id,
|
||||
'name' => $item->product ? (string) $item->product->name : 'n/a '.$item->product_id,
|
||||
'qty' => $sign * (int) $item->qty,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
if ((int) $order->payment_for === self::PAYMENT_FOR_COLLECTIVE_INVOICE) {
|
||||
if (! $order->shopping_collect_order) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($order->shopping_collect_order->shop_items as $item) {
|
||||
$rows[] = $base + [
|
||||
'product_id' => isset($item['pid']) ? (int) $item['pid'] : null,
|
||||
'number' => (string) ($item['number'] ?? ''),
|
||||
'name' => (string) ($item['name'] ?? ''),
|
||||
'qty' => $sign * (int) ($item['qty'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
foreach ($order->shopping_order_items as $item) {
|
||||
$rows[] = array_merge($base, [
|
||||
'product_id' => $item->product_id ? (int) $item->product_id : null,
|
||||
'number' => $item->product ? (string) $item->product->number : 'n/a '.$item->product_id,
|
||||
'name' => $item->product ? (string) $item->product->name : 'n/a '.$item->product_id,
|
||||
'qty' => $sign * (int) $item->qty,
|
||||
'note' => trim($note.($item->comp ? ' Kompensation '.$item->comp : '')),
|
||||
]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eigentliche Stornorechnung hat cancellation=true und cancellation_id=null;
|
||||
* die stornierte Originalrechnung traegt cancellation=true MIT cancellation_id.
|
||||
*/
|
||||
private function isCancellationInvoice(UserInvoice $invoice): bool
|
||||
{
|
||||
return (bool) $invoice->cancellation && $invoice->cancellation_id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function orderRelations(string $prefix = ''): array
|
||||
{
|
||||
return array_map(fn (string $relation): string => $prefix.$relation, [
|
||||
'shopping_user',
|
||||
'shopping_order_items.product',
|
||||
'homeparty.homeparty_order_items.product',
|
||||
'shopping_collect_order',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set/Kit-Anlagen der uebergebenen Produkte.
|
||||
*
|
||||
* @param array<int, int> $productIds
|
||||
* @return array<int, array<int, array{product_id: int, number: string, name: string, quantity: int}>>
|
||||
*/
|
||||
private function getBundleMap(array $productIds): array
|
||||
{
|
||||
if (! $productIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
|
||||
$bundles = ProductBundle::query()
|
||||
->with('bundleProduct')
|
||||
->whereIn('product_id', array_unique($productIds))
|
||||
->orderBy('pos')
|
||||
->get();
|
||||
|
||||
foreach ($bundles as $bundle) {
|
||||
if (! $bundle->bundleProduct) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$bundle->product_id][] = [
|
||||
'product_id' => (int) $bundle->bundle_product_id,
|
||||
'number' => (string) $bundle->bundleProduct->number,
|
||||
'name' => (string) $bundle->bundleProduct->name,
|
||||
'quantity' => max(1, (int) $bundle->quantity),
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Absatzmengen nach Versand: `shopping_orders.shipped` ist nur ein Status ohne
|
||||
* Zeitstempel, deshalb war eine Auswertung "was ist in Monat X rausgegangen"
|
||||
* bisher nicht moeglich. `shipped_at` haelt den Versandzeitpunkt fest,
|
||||
* `shipped_at_source` dokumentiert, woher er stammt (DHL-Label, Live-Erfassung
|
||||
* oder Schaetzung aus dem Backfill).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('shopping_orders', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('shopping_orders', 'shipped_at')) {
|
||||
$table->timestamp('shipped_at')->nullable()->index()->after('tracking');
|
||||
}
|
||||
if (! Schema::hasColumn('shopping_orders', 'shipped_at_source')) {
|
||||
$table->string('shipped_at_source', 20)->nullable()->after('shipped_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('shopping_orders', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('shopping_orders', 'shipped_at_source')) {
|
||||
$table->dropColumn('shipped_at_source');
|
||||
}
|
||||
if (Schema::hasColumn('shopping_orders', 'shipped_at')) {
|
||||
$table->dropColumn('shipped_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -18,24 +18,34 @@
|
|||
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
{{ __('navigation.sales_volumes') }} {{ __('navigation.products') }} (nach Rechnungen)
|
||||
{{ __('navigation.sales_volumes') }} {{ __('navigation.products') }} – {{ $basis_label }}
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{!! Form::open(['action' => route('admin_payments_sales_volumes_download'), 'class' => '']) !!}
|
||||
{!! Form::hidden('key', 'value') !!}
|
||||
<button type="submit" name="action" value="export" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> Export kompakte Liste als xls</button>
|
||||
<button type="submit" name="action" value="export" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> Export Produkte als xls</button>
|
||||
|
||||
|
||||
|
||||
<button type="submit" name="action" value="export_articles" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> Export Einzelartikel als xls</button>
|
||||
|
||||
|
||||
<button type="submit" name="action" value="exportfull_paid_invoice" class="btn btn-sm btn-primary mb-2"><i class="ion ion-md-download"></i> Export vollständige Liste als xls</button>
|
||||
|
||||
<hr>
|
||||
<div class="form-row align-items-center px-0 pb-2 pt-0">
|
||||
<div class="col-12 col-sm-4 col-md-4 col-lg-4 mb-1">
|
||||
<select class="custom-select" id="payment_sales_vol_filter_basis" name="payment_sales_vol_filter_basis">
|
||||
@foreach($filter_basis as $key=>$value)
|
||||
<option value="{{$key}}" @if($basis === $key) selected @endif>{{$value}}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-4 col-lg-4 mb-1">
|
||||
<select class="custom-select on_change_select_filter" name="payment_sales_vol_filter_month">
|
||||
@foreach($filter_months as $key=>$value)
|
||||
<option value="{{$key}}" @if(session('payment_sales_vol_filter_month') == $key) selected @endif>{{$value}}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-md-4 col-lg-4 mb-1">
|
||||
<select class="custom-select on_change_select_filter" name="payment_sales_vol_filter_year">
|
||||
|
|
@ -47,10 +57,36 @@
|
|||
</div>
|
||||
{!! Form::close() !!}
|
||||
|
||||
<div class="card">
|
||||
@if($basis === \App\Services\SalesVolumeService::BASIS_SHIPPING)
|
||||
<div class="alert alert-light border small mb-3">
|
||||
<i class="fa fa-truck text-muted"></i>
|
||||
Gezählt wird, was im gewählten Monat versendet wurde (Warenausgang), inklusive der externen/API-Bestellungen.
|
||||
Stornos und Retouren werden im Monat der Stornorechnung abgezogen.
|
||||
<br>
|
||||
<strong>Datengrundlage:</strong> {{ $quality['orders'] }} versendete Bestellungen im Monat,
|
||||
davon {{ $quality['estimated'] }} mit geschätztem Versanddatum ({{ $quality['estimated_percent'] }} %).
|
||||
@if($quality['estimated_percent'] > 0)
|
||||
Geschätzt wird das Versanddatum bei Bestellungen ohne DHL-Label – also aus der Zeit vor der DHL-Anbindung (produktiv ab 02/2026)
|
||||
oder bei anderem Versandweg. Grundlage ist dann die letzte Änderung der Bestellung.
|
||||
@endif
|
||||
@if($quality['without_date'] > 0)
|
||||
<br><span class="text-danger">{{ $quality['without_date'] }} versendete Bestellungen haben noch kein Versanddatum – <code>php artisan sales:backfill-shipped-at --fix</code> ausführen.</span>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="alert alert-light border small mb-3">
|
||||
<i class="fa fa-file-invoice text-muted"></i>
|
||||
Gezählt wird, was im gewählten Monat fakturiert wurde (Rechnungsdatum = Zahlungseingang).
|
||||
Externe/API-Bestellungen erscheinen hier erst über die bezahlte Sammelrechnung des Partners – teils im Folgemonat.
|
||||
Stornorechnungen werden abgezogen.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h6 class="mt-3">Absatz je verkauftem Artikel <small class="text-muted">(Sets zählen als ein Set)</small></h6>
|
||||
<div class="card mb-4">
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatable-sales-volume">
|
||||
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{__('#') }}</th>
|
||||
|
|
@ -60,7 +96,27 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6>Absatz je Einzelartikel <small class="text-muted">(Sets/Kits in ihre hinterlegten Anlagen aufgelöst)</small></h6>
|
||||
<div class="card">
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatable-sales-volume-articles">
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{__('#') }}</th>
|
||||
<th>{{__('tables.product')}}</th>
|
||||
<th>{{__('tables.article_no')}}</th>
|
||||
<th>{{__('tables.quantity')}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -73,46 +129,66 @@
|
|||
|
||||
$( document ).ready(function() {
|
||||
|
||||
function filterData(d) {
|
||||
d.payment_sales_vol_filter_month = $('select[name=payment_sales_vol_filter_month]').val();
|
||||
d.payment_sales_vol_filter_year = $('select[name=payment_sales_vol_filter_year]').val();
|
||||
d.payment_sales_vol_filter_basis = $('select[name=payment_sales_vol_filter_basis]').val();
|
||||
}
|
||||
|
||||
var oTable = $('#datatable-sales-volume').DataTable({
|
||||
var tableOptions = {
|
||||
"processing": true,
|
||||
"serverSide": true,
|
||||
"stateSave": true,
|
||||
"searching": false,
|
||||
ajax: {
|
||||
url: '{!! route('admin_payments_sales_volumes_datatable') !!}',
|
||||
data: function(d) {
|
||||
d.payment_sales_vol_filter_month = $('select[name=payment_sales_vol_filter_month]').val();
|
||||
d.payment_sales_vol_filter_year = $('select[name=payment_sales_vol_filter_year]').val();
|
||||
}
|
||||
},
|
||||
"order": [[0, "asc" ]],
|
||||
"columns": [
|
||||
{ data: 'id', orderable: true, searchable: false },
|
||||
{ data: 'name', name: 'name', orderable: true, searchable: false },
|
||||
{ data: 'number', name: 'number', orderable: true, searchable: false },
|
||||
{ data: 'value', name: 'value', orderable: true, searchable: false },
|
||||
|
||||
],
|
||||
"bLengthChange": false,
|
||||
"iDisplayLength": 100,
|
||||
"language": {
|
||||
"url": "/js/datatables-{{ \App::getLocale() }}.json"
|
||||
}
|
||||
};
|
||||
|
||||
var oTable = $('#datatable-sales-volume').DataTable($.extend({}, tableOptions, {
|
||||
ajax: {
|
||||
url: '{!! route('admin_payments_sales_volumes_datatable') !!}',
|
||||
data: function(d) {
|
||||
filterData(d);
|
||||
d.list = 'products';
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
var oTableArticles = $('#datatable-sales-volume-articles').DataTable($.extend({}, tableOptions, {
|
||||
ajax: {
|
||||
url: '{!! route('admin_payments_sales_volumes_datatable') !!}',
|
||||
data: function(d) {
|
||||
filterData(d);
|
||||
d.list = 'articles';
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
$('#payment_sales_vol_filter_basis').on('change', function(){
|
||||
window.location = '{!! route('admin_payments_sales_volumes') !!}?payment_sales_vol_filter_basis=' + $(this).val();
|
||||
});
|
||||
|
||||
$('select.on_change_select_filter').on('change', function(){
|
||||
oTable.draw();
|
||||
oTableArticles.draw();
|
||||
});
|
||||
|
||||
|
||||
$('input.on_keyup_input_filter').on('keyup', function(){
|
||||
oTable.draw();
|
||||
oTableArticles.draw();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
|
|
|||
269
tests/Feature/SalesVolumeServiceTest.php
Normal file
269
tests/Feature/SalesVolumeServiceTest.php
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductBundle;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\SalesVolumeService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function salesVolumeSeed(): array
|
||||
{
|
||||
$country = Country::create([
|
||||
'code' => 'DE',
|
||||
'phone' => '49',
|
||||
'en' => 'Germany',
|
||||
'de' => 'Deutschland',
|
||||
'es' => 'Alemania',
|
||||
'fr' => 'Allemagne',
|
||||
'it' => 'Germania',
|
||||
'ru' => 'Германия',
|
||||
'eu_country' => true,
|
||||
]);
|
||||
|
||||
$account = UserAccount::create([
|
||||
'first_name' => 'Klaus',
|
||||
'last_name' => 'Kunde',
|
||||
'address' => 'Hauptstrasse 1',
|
||||
'zipcode' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'country_id' => $country->id,
|
||||
]);
|
||||
|
||||
$user = User::forceCreate([
|
||||
'account_id' => $account->id,
|
||||
'email' => 'klaus@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $user->id,
|
||||
'billing_firstname' => 'Klaus',
|
||||
'billing_lastname' => 'Kunde',
|
||||
'billing_address' => 'Hauptstrasse 1',
|
||||
'billing_zipcode' => '10115',
|
||||
'billing_city' => 'Berlin',
|
||||
'billing_country_id' => $country->id,
|
||||
'billing_email' => 'klaus@example.com',
|
||||
'shipping_country_id' => $country->id,
|
||||
'same_as_billing' => true,
|
||||
]);
|
||||
|
||||
$shop = UserShop::create(['user_id' => $user->id, 'name' => 'Testshop', 'active' => 1]);
|
||||
|
||||
return [$country, $shoppingUser, $shop, $user];
|
||||
}
|
||||
|
||||
function salesVolumeProduct(string $name, string $number, float $price = 16.9): Product
|
||||
{
|
||||
return Product::create([
|
||||
'name' => $name,
|
||||
'title' => $name,
|
||||
'number' => $number,
|
||||
'price' => $price,
|
||||
'tax' => 19,
|
||||
'active' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
function salesVolumeOrder(array $seed, array $attributes = []): ShoppingOrder
|
||||
{
|
||||
[$country, $shoppingUser, $shop, $user] = $seed;
|
||||
|
||||
return ShoppingOrder::create(array_merge([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => $user->id,
|
||||
'country_id' => $country->id,
|
||||
'user_shop_id' => $shop->id,
|
||||
'payment_for' => 3,
|
||||
'mode' => 'live',
|
||||
'txaction' => 'paid',
|
||||
'shipped' => 0,
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
it('setzt das Versanddatum beim Wechsel auf versendet', function () {
|
||||
$order = salesVolumeOrder(salesVolumeSeed());
|
||||
|
||||
expect($order->shipped_at)->toBeNull();
|
||||
|
||||
$order->shipped = 2;
|
||||
$order->save();
|
||||
|
||||
expect($order->fresh()->shipped_at)->not->toBeNull()
|
||||
->and($order->fresh()->shipped_at_source)->toBe(ShoppingOrder::SHIPPED_AT_SOURCE_LIVE);
|
||||
});
|
||||
|
||||
it('ueberschreibt ein vorhandenes Versanddatum nicht', function () {
|
||||
$order = salesVolumeOrder(salesVolumeSeed(), [
|
||||
'shipped' => 2,
|
||||
'shipped_at' => '2026-05-04 10:00:00',
|
||||
'shipped_at_source' => ShoppingOrder::SHIPPED_AT_SOURCE_DHL,
|
||||
]);
|
||||
|
||||
$order->tracking = '123';
|
||||
$order->save();
|
||||
|
||||
expect($order->fresh()->shipped_at->format('Y-m-d'))->toBe('2026-05-04')
|
||||
->and($order->fresh()->shipped_at_source)->toBe(ShoppingOrder::SHIPPED_AT_SOURCE_DHL);
|
||||
});
|
||||
|
||||
it('zaehlt auf Versandbasis den Monat des Warenausgangs, nicht den der Rechnung', function () {
|
||||
$seed = salesVolumeSeed();
|
||||
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
|
||||
|
||||
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-03 09:00:00']);
|
||||
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 4]);
|
||||
|
||||
// Rechnung liegt im Vormonat (Zahlung Ende Juni), Versand erfolgte im Juli
|
||||
UserInvoice::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 6,
|
||||
'year' => 2026,
|
||||
'date' => '2026-06-30',
|
||||
'full_number' => 'RE-1',
|
||||
'number' => 1,
|
||||
]);
|
||||
|
||||
$shippingJuly = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
|
||||
$invoiceJuly = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
|
||||
$invoiceJune = (new SalesVolumeService(6, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
|
||||
|
||||
expect($shippingJuly[$product->id]['value'])->toBe(4)
|
||||
->and($invoiceJuly)->toBeEmpty()
|
||||
->and($invoiceJune[$product->id]['value'])->toBe(4);
|
||||
});
|
||||
|
||||
it('zaehlt externe API-Bestellungen auf Versandbasis mit', function () {
|
||||
$seed = salesVolumeSeed();
|
||||
$product = salesVolumeProduct('Direktsaft', '6030100', 26.9);
|
||||
|
||||
$order = salesVolumeOrder($seed, [
|
||||
'payment_for' => 7,
|
||||
'txaction' => 'extern_paid',
|
||||
'shipped' => 2,
|
||||
'shipped_at' => '2026-07-10 09:00:00',
|
||||
]);
|
||||
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 3]);
|
||||
|
||||
$volumes = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
|
||||
|
||||
expect($volumes[$product->id]['value'])->toBe(3);
|
||||
});
|
||||
|
||||
it('zieht Stornorechnungen ab statt sie zu addieren', function () {
|
||||
$seed = salesVolumeSeed();
|
||||
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
|
||||
|
||||
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-05 09:00:00']);
|
||||
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 2]);
|
||||
|
||||
$invoice = UserInvoice::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 7,
|
||||
'year' => 2026,
|
||||
'date' => '2026-07-05',
|
||||
'full_number' => 'RE-1',
|
||||
'number' => 1,
|
||||
]);
|
||||
|
||||
$cancellation = UserInvoice::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 7,
|
||||
'year' => 2026,
|
||||
'date' => '2026-07-20',
|
||||
'full_number' => 'RE-2',
|
||||
'number' => 2,
|
||||
'cancellation' => true,
|
||||
]);
|
||||
|
||||
$invoice->update(['cancellation' => true, 'cancellation_id' => $cancellation->id]);
|
||||
|
||||
$invoiceBasis = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_INVOICE))->getProductVolumes();
|
||||
$shippingBasis = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
|
||||
|
||||
// Rechnung +2, Storno -2; auf Versandbasis Warenausgang +2 und Retoure -2
|
||||
expect($invoiceBasis[$product->id]['value'])->toBe(0)
|
||||
->and($shippingBasis[$product->id]['value'])->toBe(0);
|
||||
});
|
||||
|
||||
it('zieht Stornos ohne vorherigen Versand auf Versandbasis nicht ab', function () {
|
||||
$seed = salesVolumeSeed();
|
||||
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
|
||||
|
||||
$order = salesVolumeOrder($seed, ['shipped' => 10]);
|
||||
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 2]);
|
||||
|
||||
UserInvoice::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'month' => 7,
|
||||
'year' => 2026,
|
||||
'date' => '2026-07-20',
|
||||
'full_number' => 'RE-2',
|
||||
'number' => 2,
|
||||
'cancellation' => true,
|
||||
]);
|
||||
|
||||
$volumes = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getProductVolumes();
|
||||
|
||||
expect($volumes)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('loest Sets in ihre Anlagen auf', function () {
|
||||
$seed = salesVolumeSeed();
|
||||
$single = salesVolumeProduct('Direktsaft', '6030100', 26.9);
|
||||
$set = salesVolumeProduct('6 x Direktsaft', '5010050', 129.9);
|
||||
ProductBundle::create([
|
||||
'product_id' => $set->id,
|
||||
'bundle_product_id' => $single->id,
|
||||
'quantity' => 6,
|
||||
'pos' => 1,
|
||||
]);
|
||||
|
||||
$order = salesVolumeOrder($seed, ['shipped' => 2, 'shipped_at' => '2026-07-08 09:00:00']);
|
||||
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $set->id, 'qty' => 2]);
|
||||
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $single->id, 'qty' => 1]);
|
||||
|
||||
$service = new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING);
|
||||
|
||||
$products = $service->getProductVolumes();
|
||||
$articles = $service->getArticleVolumes();
|
||||
|
||||
// Produktliste: Set bleibt eine eigene Zeile
|
||||
expect($products[$set->id]['value'])->toBe(2)
|
||||
->and($products[$single->id]['value'])->toBe(1);
|
||||
|
||||
// Einzelartikel: 2 Sets a 6 Flaschen + 1 Einzelflasche, Set-Zeile entfaellt
|
||||
expect($articles[$single->id]['value'])->toBe(13)
|
||||
->and($articles)->not->toHaveKey($set->id);
|
||||
});
|
||||
|
||||
it('weist den Anteil geschaetzter Versanddaten aus', function () {
|
||||
$seed = salesVolumeSeed();
|
||||
$product = salesVolumeProduct('Aloe Gel', '8020100', 16.9);
|
||||
|
||||
foreach ([ShoppingOrder::SHIPPED_AT_SOURCE_DHL, ShoppingOrder::SHIPPED_AT_SOURCE_ESTIMATED] as $source) {
|
||||
$order = salesVolumeOrder($seed, [
|
||||
'shipped' => 2,
|
||||
'shipped_at' => '2026-07-08 09:00:00',
|
||||
'shipped_at_source' => $source,
|
||||
]);
|
||||
ShoppingOrderItem::create(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1]);
|
||||
}
|
||||
|
||||
$stats = (new SalesVolumeService(7, 2026, SalesVolumeService::BASIS_SHIPPING))->getQualityStats();
|
||||
|
||||
expect($stats['orders'])->toBe(2)
|
||||
->and($stats['estimated'])->toBe(1)
|
||||
->and($stats['estimated_percent'])->toBe(50.0);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue