mivita/app/Services/SalesVolumeService.php
Kevin d01b9bca3a 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>
2026-09-23 15:42:39 +00:00

425 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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