- 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>
425 lines
13 KiB
PHP
425 lines
13 KiB
PHP
<?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;
|
||
}
|
||
}
|