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
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue