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:
Kevin 2026-09-23 15:42:39 +00:00
parent a21bebc932
commit d01b9bca3a
7 changed files with 1139 additions and 432 deletions

View 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;
}
}