Warenwirtschaft: AP-13 API-Bestellungen — Protokoll mit SKU-Fehlermarkierung

Neuer Navigationspunkt „API-Bestellungen": jede wp/order-Übertragung wird
protokolliert (api_order_logs) — auch abgelehnte (Kunde unbekannt, Bestellung
existiert bereits, Speicherfehler). Positionen ohne Produktzuordnung
(unbekannte WP-Nummer) markieren den Eintrag rot; das Modal zeigt den Inhalt
der Übertragung je Position (SKU, Produkt, Menge, Preis, Meldung übersetzt).
Dazu SKU-Konsistenzprüfung auf der Seite: doppelte WP-Nummern aktiver Produkte
(Warnung) und aktive Produkte ohne WP-Nummer (aufklappbar). Behebt die stille
Lücke, dass „article not found" nur in der API-Antwort unterging und die
Position kommentarlos in Bestellung und Bestandsabzug fehlte (Plan §4).
ShoppingUserController per Pint formatiert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-16 08:04:16 +00:00
parent 211aeed384
commit 71d5591e7b
8 changed files with 859 additions and 170 deletions

View file

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\ApiOrderLog;
use App\Models\Product;
use Illuminate\Http\Request;
/**
* AP-13: Übersicht der über die Partnercenter-API eingegangenen Bestell-Übertragungen.
* Einträge mit fehlgeschlagener Produktzuordnung (unbekannte SKU) sind markiert;
* das Modal zeigt den Inhalt der Übertragung je Position.
*/
class ApiOrderLogController extends Controller
{
private const INDEX_LIMIT = 500;
public function index(Request $request)
{
$limit = $request->query('all') ? null : self::INDEX_LIMIT;
$logs = ApiOrderLog::query()
->with(['shoppingOrder', 'shoppingUser'])
->orderByDesc('id')
->when($limit !== null, fn ($query) => $query->limit($limit))
->get();
$errorCount = ApiOrderLog::query()->where('has_errors', true)->count();
// SKU-Konsistenz (Plan §4): doppelte WP-Nummern brechen die Zuordnung,
// aktive Produkte ohne WP-Nummer können nie per API zugeordnet werden.
$duplicateNumbers = Product::query()
->where('active', true)
->whereNotNull('wp_number')
->where('wp_number', '!=', 0)
->selectRaw('wp_number, COUNT(*) as sku_count')
->groupBy('wp_number')
->havingRaw('COUNT(*) > 1')
->pluck('wp_number');
$duplicateSkuProducts = $duplicateNumbers->isEmpty()
? collect()
: Product::query()
->where('active', true)
->whereIn('wp_number', $duplicateNumbers)
->orderBy('wp_number')
->orderBy('name')
->get(['id', 'name', 'wp_number']);
$missingSkuProducts = Product::query()
->where('active', true)
->where(fn ($query) => $query->whereNull('wp_number')->orWhere('wp_number', 0))
->orderBy('name')
->get(['id', 'name']);
return view('admin.inventory.api-orders.index', [
'logs' => $logs,
'listLimit' => $limit,
'errorCount' => $errorCount,
'duplicateSkuProducts' => $duplicateSkuProducts,
'missingSkuProducts' => $missingSkuProducts,
]);
}
}

View file

@ -2,7 +2,9 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Mail\MailCheckout;
use App\Models\ApiOrderLog;
use App\Models\Country;
use App\Models\Product;
use App\Models\ShippingCountry;
@ -10,30 +12,27 @@ use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Services\CustomerPriority;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\Shop;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Constraint\Count;
use Yard;
class ShoppingUserController extends Controller
{
//protected static API_MAIL = 'api.thomas.krummel@gmail.com';
//protected static API_PASS = 'UF(Q<9knap!ev3vH?5~!b8DP';
//URL api.gruene-seele.bio
// protected static API_MAIL = 'api.thomas.krummel@gmail.com';
// protected static API_PASS = 'UF(Q<9knap!ev3vH?5~!b8DP';
// URL api.gruene-seele.bio
protected $successStatus = 200;
protected $member_id = 2; //thomas.krummel@gmail.com
protected $member_id = 2; // thomas.krummel@gmail.com
/**
* @param Request $request
* wp_order_numbers[1234, 1234]
* @return \Illuminate\Http\JsonResponse
* @param Request $request
* wp_order_numbers[1234, 1234]
* @return JsonResponse
*/
public function status(Request $request)
{
@ -42,24 +41,23 @@ class ShoppingUserController extends Controller
'wp_order_numbers' => 'required',
]);
if(!is_array($request->wp_order_numbers)){
if (! is_array($request->wp_order_numbers)) {
$wp_order_numbers = json_decode($request->wp_order_numbers);
}else{
} else {
$wp_order_numbers = $request->wp_order_numbers;
}
if(!$wp_order_numbers || !is_array($wp_order_numbers)){
if (! $wp_order_numbers || ! is_array($wp_order_numbers)) {
return response()->json([
'success' => false,
'message' => 'wp_order_numbers need as json [1234, 1234] ',
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
$status = [];
foreach ($wp_order_numbers as $wp_order_number){
foreach ($wp_order_numbers as $wp_order_number) {
$shopping_user = ShoppingUser::where('wp_order_number', '=', $wp_order_number)->first();
$status[] = [
'wp_order_number' => $wp_order_number,
@ -72,15 +70,15 @@ class ShoppingUserController extends Controller
return response()->json([
'success' => true,
'data' => $status,
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 200);
}
/**
* @param Request $request
* wp_order_number [1234]
* @return \Illuminate\Http\JsonResponse
* @param Request $request
* wp_order_number [1234]
* @return JsonResponse
*/
public function cancel(Request $request)
{
@ -88,31 +86,31 @@ class ShoppingUserController extends Controller
'wp_order_number' => 'required|int',
]);
$shopping_user = ShoppingUser::where('wp_order_number', '=', $request->wp_order_number)->first();
if (!$shopping_user) {
if (! $shopping_user) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' not found',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' not found',
'order' => false,
'status' => false,
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
if(!$shopping_user->shopping_order){
if (! $shopping_user->shopping_order) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' has no order',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' has no order',
'order' => false,
'status' => $shopping_user->getAPIShippedType(),
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
if($shopping_user->shopping_order->shipped > 0){
if ($shopping_user->shopping_order->shipped > 0) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' can not cancel',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' can not cancel',
'order' => true,
'status' => $shopping_user->getAPIShippedType(),
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
@ -121,19 +119,18 @@ class ShoppingUserController extends Controller
return response()->json([
'success' => true,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' is cancel',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' is cancel',
'order' => true,
'status' => $shopping_user->getAPIShippedType(),
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 200);
}
/**
* @param Request $request
* wp_order_number [1234]
* @return \Illuminate\Http\JsonResponse
* @param Request $request
* wp_order_number [1234]
* @return JsonResponse
*/
public function open(Request $request)
{
@ -141,31 +138,31 @@ class ShoppingUserController extends Controller
'wp_order_number' => 'required|int',
]);
$shopping_user = ShoppingUser::where('wp_order_number', '=', $request->wp_order_number)->first();
if (!$shopping_user) {
if (! $shopping_user) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' not found',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' not found',
'order' => false,
'status' => false,
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
if(!$shopping_user->shopping_order){
if (! $shopping_user->shopping_order) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' has no order',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' has no order',
'order' => false,
'status' => $shopping_user->getAPIShippedType(),
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
if($shopping_user->shopping_order->shipped !== 10){
if ($shopping_user->shopping_order->shipped !== 10) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' can not open',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' can not open',
'order' => true,
'status' => $shopping_user->getAPIShippedType(),
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
@ -174,44 +171,43 @@ class ShoppingUserController extends Controller
return response()->json([
'success' => true,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' is open',
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' is open',
'order' => true,
'status' => $shopping_user->getAPIShippedType(),
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 200);
}
/**
* @param Request $request
* wp_order_numbers [1234, 1234]
* @return \Illuminate\Http\JsonResponse
* @param Request $request
* wp_order_numbers [1234, 1234]
* @return JsonResponse
*/
public function show(Request $request)
{
//$this->member_id = auth()->user()->m_sponsor;
// $this->member_id = auth()->user()->m_sponsor;
$request->validate([
'wp_order_numbers' => 'required',
]);
if(!is_array($request->wp_order_numbers)){
if (! is_array($request->wp_order_numbers)) {
$wp_order_numbers = json_decode($request->wp_order_numbers);
}else{
} else {
$wp_order_numbers = $request->wp_order_numbers;
}
if(!$wp_order_numbers || !is_array($wp_order_numbers)){
if (! $wp_order_numbers || ! is_array($wp_order_numbers)) {
return response()->json([
'success' => false,
'message' => 'wp_order_numbers need as json [1234, 1234] ',
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
$data = [];
foreach ($wp_order_numbers as $wp_order_number){
foreach ($wp_order_numbers as $wp_order_number) {
$shopping_user = ShoppingUser::where('wp_order_number', '=', $wp_order_number)->first();
$user = false;
$order = false;
@ -222,22 +218,22 @@ class ShoppingUserController extends Controller
$data[] = [
'wp_order_number' => $wp_order_number,
'user' => $user,
'order' => $order,
'order' => $order,
'customer_number' => $shopping_user ? $shopping_user->number : false,
'member_email' => ($shopping_user && $shopping_user->member) ? $shopping_user->member->email : false,
'status' => $shopping_user ? $shopping_user->getAPIShippedType() : false, ];
}
return response()->json([
'success' => true,
'data' => $data,
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 200);
}
/**
* @param Request $request
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function store(Request $request)
{
@ -264,11 +260,12 @@ class ShoppingUserController extends Controller
$shopping_user = ShoppingUser::create($data);
//Kundenhoheit prüfen
// Kundenhoheit prüfen
$priority = CustomerPriority::checkOne($shopping_user, true, false, true);
\App\Services\Shop::newUserOrder($shopping_user->number);
//exists //like //update
Shop::newUserOrder($shopping_user->number);
// exists //like //update
$user = $this->prepareForShow($shopping_user);
return response()->json([
'success' => true,
'data' => [
@ -278,14 +275,13 @@ class ShoppingUserController extends Controller
'customer_number' => $shopping_user->number,
'member_email' => ($shopping_user && $shopping_user->member) ? $shopping_user->member->email : false,
],
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 200);
}
/**
* @param Request $request
* @return \Illuminate\Http\JsonResponse
* @return JsonResponse
*/
public function update(Request $request)
{
@ -293,40 +289,42 @@ class ShoppingUserController extends Controller
'wp_order_number' => 'required|int',
]);
$shopping_user = ShoppingUser::where('wp_order_number', '=', $request->wp_order_number)->first();
if (!$shopping_user) {
if (! $shopping_user) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' not found',
'time' => Carbon::now()->toDateTimeString()
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' not found',
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
$data = $this->prepareForUpdate($request->all());
//Kundenhoheit prüfen
// Kundenhoheit prüfen
$priority = CustomerPriority::checkChangeOne($shopping_user, $data, true);
$updated = $shopping_user->fill($data)->save();
\App\Services\Shop::newUserOrder($shopping_user->number);
Shop::newUserOrder($shopping_user->number);
if ($updated){
if ($updated) {
$user = $this->prepareForShow($shopping_user);
$order = $this->prepareForShowOrder($shopping_user->shopping_order);
return response()->json([
'success' => true,
'data' => [
'wp_order_number' => $shopping_user->wp_order_number,
'user' => $user,
'order' => $order,
'order' => $order,
'customer_priority' => $priority,
'customer_number' => $shopping_user ? $shopping_user->number : false,
'member_email' => ($shopping_user && $shopping_user->member) ? $shopping_user->member->email : false,
'status' => $shopping_user ? $shopping_user->getAPIShippedType() : false,
],
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 200);
}
return response()->json([
'success' => false,
'message' => 'Entry could not be updated'
'message' => 'Entry could not be updated',
], 500);
}
@ -337,27 +335,31 @@ class ShoppingUserController extends Controller
'wp_order' => 'required',
]);
$shopping_user = ShoppingUser::where('wp_order_number', '=', $request->wp_order_number)->first();
if (!$shopping_user) {
if (! $shopping_user) {
// AP-13: jede eingehende Bestell-Übertragung protokollieren — auch abgelehnte
ApiOrderLog::recordFailure($request->wp_order_number, null, $request->wp_order, __('WP-Bestellnummer unbekannt — keine Kundendaten übertragen'));
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' not found',
'time' => Carbon::now()->toDateTimeString()
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' not found',
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
if($shopping_user->shopping_order){
if ($shopping_user->shopping_order) {
ApiOrderLog::recordFailure($request->wp_order_number, $shopping_user, $request->wp_order, __('Bestellung existiert bereits — Übertragung abgelehnt'));
return response()->json([
'success' => false,
'message' => 'Order with wp_order_number ' . $request->wp_order_number . ' exists',
'time' => Carbon::now()->toDateTimeString()
'message' => 'Order with wp_order_number '.$request->wp_order_number.' exists',
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
if(!is_array($request->wp_order)){
if (! is_array($request->wp_order)) {
$wp_order = json_decode($request->wp_order);
}else{
} else {
$wp_order = $request->wp_order;
}
@ -372,9 +374,12 @@ class ShoppingUserController extends Controller
$wp_order = $this->prepareOrder($wp_order, $shopping_user, $wp_invoice_path, $wp_notice);
if ($wp_order){
if ($wp_order) {
// AP-13: Übertragung inkl. SKU-Zuordnungsergebnis je Position protokollieren
ApiOrderLog::recordTransfer($request->wp_order_number, $shopping_user, $shopping_user->shopping_order, $wp_order);
$user = $this->prepareForShow($shopping_user);
$order = $this->prepareForShowOrder($shopping_user->shopping_order);
return response()->json([
'success' => true,
'data' => [
@ -389,12 +394,14 @@ class ShoppingUserController extends Controller
'member_email' => ($shopping_user && $shopping_user->member) ? $shopping_user->member->email : false,
'status' => $shopping_user->getAPIShippedType(),
],
'time' => Carbon::now()->toDateTimeString()
'time' => Carbon::now()->toDateTimeString(),
], 200);
}
ApiOrderLog::recordFailure($request->wp_order_number, $shopping_user, $request->wp_order, __('Bestellung konnte nicht gespeichert werden'));
return response()->json([
'success' => false,
'message' => 'Order could not be stored'
'message' => 'Order could not be stored',
], 500);
}
@ -404,16 +411,16 @@ class ShoppingUserController extends Controller
'wp_order_number' => 'required|int',
]);
$shopping_user = ShoppingUser::where('wp_order_number', '=', $request->wp_order_number)->where('mode', '=', 'dev')->first();
if (!$shopping_user) {
if (! $shopping_user) {
return response()->json([
'success' => false,
'message' => 'Entry with wp_order_number ' . $request->wp_order_number . ' not found or mode != dev',
'time' => Carbon::now()->toDateTimeString()
'message' => 'Entry with wp_order_number '.$request->wp_order_number.' not found or mode != dev',
'time' => Carbon::now()->toDateTimeString(),
], 400);
}
$shopping_order = $shopping_user->shopping_order;
if($shopping_order){
foreach ($shopping_order->shopping_order_items as $shopping_order_item){
if ($shopping_order) {
foreach ($shopping_order->shopping_order_items as $shopping_order_item) {
$shopping_order_item->delete();
}
$shopping_order->delete();
@ -422,68 +429,72 @@ class ShoppingUserController extends Controller
$shopping_user->save();
if ($shopping_user->delete()) {
return response()->json([
'success' => true
'success' => true,
]);
}
return response()->json([
'success' => false,
'message' => 'Entry could not be deleted'
'message' => 'Entry could not be deleted',
], 500);
}
private function prepareForShow($shopping_user){
private function prepareForShow($shopping_user)
{
if(!$shopping_user){
if (! $shopping_user) {
return false;
}
$shopping_user_data = $shopping_user->toArray();
$needs = ['wp_order_number', 'wp_order_date', 'billing_company', 'billing_firstname', 'billing_lastname', 'billing_address', 'billing_address_2', 'billing_zipcode', 'billing_city', 'billing_phone', 'billing_email',
'same_as_billing', 'shipping_company', 'shipping_firstname', 'shipping_lastname', 'shipping_address', 'shipping_address_2', 'shipping_zipcode', 'shipping_city', 'shipping_phone',
'created_at', 'updated_at', 'user_deleted_at']; //'has_buyed', 'subscribed',
'created_at', 'updated_at', 'user_deleted_at']; // 'has_buyed', 'subscribed',
//$salutation = array('mr' => 1, 'ms' => 2);
// $salutation = array('mr' => 1, 'ms' => 2);
$ret = [];
foreach ($shopping_user_data as $key=>$value){
foreach ($shopping_user_data as $key => $value) {
if($key === 'billing_country_id'){
if ($key === 'billing_country_id') {
$ret['billing_country_code'] = $shopping_user->billing_country_id ? $shopping_user->billing_country->code : null;
}
if($key === 'shipping_country_id'){
if ($key === 'shipping_country_id') {
$ret['shipping_country_code'] = $shopping_user->shipping_country_id ? $shopping_user->shipping_country->code : null;
}
if($key === 'billing_salutation'){
if ($key === 'billing_salutation') {
$ret['billing_salutation'] = $shopping_user->billing_salutation === 'ms' ? 2 : 1;
}
if($key === 'shipping_salutation'){
if ($key === 'shipping_salutation') {
$ret['shipping_salutation'] = $shopping_user->shipping_salutation === 'ms' ? 2 : 1;
}
if(in_array($key, $needs)){
if (in_array($key, $needs)) {
$ret[$key] = $value;
}
}
return $ret;
}
private function prepareForShowOrder($shopping_order){
private function prepareForShowOrder($shopping_order)
{
if(!$shopping_order){
if (! $shopping_order) {
return false;
}
$ret = [
'country' => isset($shopping_order->shipping_country->country->code) ? $shopping_order->shipping_country->country->code : '',
'wp_invoice_path' => $shopping_order->wp_invoice_path,
'total' => ($shopping_order->total*100),
'shipping' => ($shopping_order->shipping*100),
'total_net' => ($shopping_order->subtotal*100),
'tax_rate' => ($shopping_order->tax_rate*100),
'tax' => ($shopping_order->tax*100),
'total_with_shipping' => ($shopping_order->total_shipping*100),
'total' => ($shopping_order->total * 100),
'shipping' => ($shopping_order->shipping * 100),
'total_net' => ($shopping_order->subtotal * 100),
'tax_rate' => ($shopping_order->tax_rate * 100),
'tax' => ($shopping_order->tax * 100),
'total_with_shipping' => ($shopping_order->total_shipping * 100),
'weight' => $shopping_order->weight,
];
$ret['items'] = [];
foreach ($shopping_order->shopping_order_items as $item){
foreach ($shopping_order->shopping_order_items as $item) {
$ret['items'][] = [
'article' => $item->product->wp_number,
'name' => $item->product->name,
@ -491,64 +502,68 @@ class ShoppingUserController extends Controller
'price' => ($item->price * 100),
];
}
return $ret;
}
private function prepareForUpdate($data){
private function prepareForUpdate($data)
{
//$salutation = array(1 => 'mr', 2 => 'ms', 3=>null);
// $salutation = array(1 => 'mr', 2 => 'ms', 3=>null);
if(isset($data['billing_salutation'])){
$data['billing_salutation'] = (int) $data['billing_salutation'];
if (isset($data['billing_salutation'])) {
$data['billing_salutation'] = (int) $data['billing_salutation'];
$data['billing_salutation'] = $data['billing_salutation'] == 2 ? 'ms' : 'mr';
}
if(isset($data['shipping_salutation'])){
$data['shipping_salutation'] = (int) $data['shipping_salutation'];
$data['shipping_salutation'] = $data['shipping_salutation'] == 2 ? 'ms' : 'mr';
if (isset($data['shipping_salutation'])) {
$data['shipping_salutation'] = (int) $data['shipping_salutation'];
$data['shipping_salutation'] = $data['shipping_salutation'] == 2 ? 'ms' : 'mr';
}
$ret = [];
$needs = [ 'billing_salutation', 'billing_company', 'billing_firstname', 'billing_lastname', 'billing_address', 'billing_address_2', 'billing_zipcode', 'billing_city', 'billing_phone', 'billing_email', 'same_as_billing',
$needs = ['billing_salutation', 'billing_company', 'billing_firstname', 'billing_lastname', 'billing_address', 'billing_address_2', 'billing_zipcode', 'billing_city', 'billing_phone', 'billing_email', 'same_as_billing',
'shipping_salutation', 'shipping_company', 'shipping_firstname', 'shipping_lastname', 'shipping_address', 'shipping_address_2', 'shipping_zipcode', 'shipping_city', 'shipping_phone'];
foreach ($data as $key=>$value){
if($key === 'billing_country_code' && isset($data['billing_country_code'])) {
$ret['billing_country_id'] = Country::getCountryIdByCodeOrOne($data['billing_country_code']);
foreach ($data as $key => $value) {
if ($key === 'billing_country_code' && isset($data['billing_country_code'])) {
$ret['billing_country_id'] = Country::getCountryIdByCodeOrOne($data['billing_country_code']);
}
if($key === 'shipping_country_code' && isset($data['shipping_country_code']) ) {
if ($key === 'shipping_country_code' && isset($data['shipping_country_code'])) {
$ret['shipping_country_id'] = Country::getCountryIdByCodeOrOne($data['shipping_country_code']);
}
if($key === 'billing_phone') {
if ($key === 'billing_phone') {
$ret['billing_phone'] = strlen($data['billing_phone']) <= 3 ? '' : $data['billing_phone'];
}
if($key === 'shipping_phone') {
if ($key === 'shipping_phone') {
$ret['shipping_phone'] = strlen($data['shipping_phone']) <= 3 ? '' : $data['shipping_phone'];
}
if(in_array($key, $needs)){
if (in_array($key, $needs)) {
$ret[$key] = $value;
}
}
return $ret;
}
private function prepareForStore($data){
private function prepareForStore($data)
{
//$salutation = array(1 => 'mr', 2 => 'ms', 3=>null);
if(isset($data['billing_salutation'])){
$data['billing_salutation'] = (int) $data['billing_salutation'];
// $salutation = array(1 => 'mr', 2 => 'ms', 3=>null);
if (isset($data['billing_salutation'])) {
$data['billing_salutation'] = (int) $data['billing_salutation'];
$data['billing_salutation'] = $data['billing_salutation'] == 2 ? 'ms' : 'mr';
}
if(isset($data['shipping_salutation'])){
$data['shipping_salutation'] = (int) $data['shipping_salutation'];
$data['shipping_salutation'] = $data['shipping_salutation'] == 2 ? 'ms' : 'mr';
if (isset($data['shipping_salutation'])) {
$data['shipping_salutation'] = (int) $data['shipping_salutation'];
$data['shipping_salutation'] = $data['shipping_salutation'] == 2 ? 'ms' : 'mr';
}
$ret = [];
$needs = [ 'billing_salutation', 'billing_company', 'billing_firstname', 'billing_lastname', 'billing_address', 'billing_address_2', 'billing_zipcode', 'billing_city', 'billing_country_id', 'billing_phone', 'billing_email',
$needs = ['billing_salutation', 'billing_company', 'billing_firstname', 'billing_lastname', 'billing_address', 'billing_address_2', 'billing_zipcode', 'billing_city', 'billing_country_id', 'billing_phone', 'billing_email',
'shipping_salutation', 'shipping_company', 'shipping_firstname', 'shipping_lastname', 'shipping_address', 'shipping_address_2', 'shipping_zipcode', 'shipping_city', 'shipping_country_id', 'shipping_phone',
'same_as_billing', //'has_buyed', 'subscribed',
'same_as_billing', // 'has_buyed', 'subscribed',
'wp_order_number', 'wp_order_date'];
foreach ($needs as $need){
foreach ($needs as $need) {
$ret[$need] = isset($data[$need]) ? $data[$need] : null;
if ($need === 'billing_country_id') {
@ -567,37 +582,38 @@ class ShoppingUserController extends Controller
$ret['wp_order_date'] = Carbon::parse($ret['wp_order_date'])->toDateTimeString();
}
if ($need === 'same_as_billing') {
$ret['same_as_billing'] = isset($data['same_as_billing']) ? (int) $data['same_as_billing'] : 1;
$ret['same_as_billing'] = isset($data['same_as_billing']) ? (int) $data['same_as_billing'] : 1;
}
}
$ret['has_buyed'] = true;
$ret['subscribed'] = false;
return $ret;
}
private function prepareOrder($wp_shopping_order, $shopping_user, $wp_invoice_path, $wp_notice){
private function prepareOrder($wp_shopping_order, $shopping_user, $wp_invoice_path, $wp_notice)
{
Yard::instance('shopping')->destroy();
$ret = [];
if(is_array($wp_shopping_order)){
if (is_array($wp_shopping_order)) {
foreach ($wp_shopping_order as $order) {
//$object = json_decode(json_encode($order), FALSE);
// $object = json_decode(json_encode($order), FALSE);
$order = (object) $order;
$error = [];
if (!isset($order->article) || !isset($order->qty) || !isset($order->price)) {
$error[] = "article parameter is missing";
if (! isset($order->article) || ! isset($order->qty) || ! isset($order->price)) {
$error[] = 'article parameter is missing';
} else {
$product = Product::whereWpNumber($order->article)->first();
if (!$product) {
$error[] = "article not found";
if (! $product) {
$error[] = 'article not found';
} else {
if ($order->price != ($product->price * 100)) {
$error[] = "different price: " . ($product->price * 100);
$error[] = 'different price: '.($product->price * 100);
}
Yard::instance('shopping')->add($product->id, $product->name, (int) $order->qty, $product->price, $product->tax, ['image' => [], 'slug' => $product->slug, 'weight' => $product->weight]);
Yard::instance('shopping')->add($product->id, $product->name, (int) $order->qty, $product->price, $product->tax, ['image' => [], 'slug' => $product->slug, 'weight' => $product->weight]);
}
}
$order->message = $error;
@ -605,7 +621,7 @@ class ShoppingUserController extends Controller
}
$ShippingCountry = ShippingCountry::whereCountryId($shopping_user->shipping_country_id)->first();
if($ShippingCountry){
if ($ShippingCountry) {
Yard::instance('shopping')->setShippingCountryWithPrice($ShippingCountry->id, 'ot');
}
$shopping_order = $this->makeShoppingOrder($shopping_user, $wp_invoice_path, $wp_notice);
@ -614,16 +630,18 @@ class ShoppingUserController extends Controller
$shopping_user->shopping_order = $shopping_order;
Yard::instance('shopping')->destroy();
}
return $ret;
}
private function makeShoppingOrder($shopping_user, $wp_invoice_path, $wp_notice){
private function makeShoppingOrder($shopping_user, $wp_invoice_path, $wp_notice)
{
$data = [
'shopping_user_id' => $shopping_user->id,
'auth_user_id' => $shopping_user->auth_user_id,
'country_id' => Yard::instance('shopping')->getShippingCountryId(),
// 'user_shop_id' => auth()->user()->user_sponsor->shop->id,
// 'user_shop_id' => auth()->user()->user_sponsor->shop->id,
'member_id' => $shopping_user->member_id,
'total' => Yard::instance('shopping')->total(2, '.', ''),
'subtotal' => Yard::instance('shopping')->subtotal(2, '.', ''),
@ -641,20 +659,20 @@ class ShoppingUserController extends Controller
'mode' => $shopping_user->mode,
];
$shopping_order = $shopping_user->shopping_order;
if($shopping_order){
if ($shopping_order) {
$shopping_order->fill($data);
$shopping_order->save();
}else{
$shopping_order= ShoppingOrder::create($data);
} else {
$shopping_order = ShoppingOrder::create($data);
}
$items = Yard::instance('shopping')->content();
$shopping_order->shopping_order_items()->each(function($model) use ($items, $shopping_order) {
$shopping_order->shopping_order_items()->each(function ($model) use ($items, $shopping_order) {
foreach ($items as $item) {
if ($model->row_id === $item->rowId) {
$model->fill([
'shopping_order_id' => $shopping_order->id,
'row_id' => $item->rowId,
'row_id' => $item->rowId,
'product_id' => $item->id,
'qty' => $item->qty,
'price' => $item->price,
@ -662,42 +680,43 @@ class ShoppingUserController extends Controller
'tax_rate' => $item->taxRate,
'slug' => $item->options->slug,
])->save();
return false;
}
}
return $model->delete();
});
foreach ($items as $item) {
if (!ShoppingOrderItem::where('shopping_order_id', $shopping_order->id)->where('row_id', $item->rowId)->count())
if (! ShoppingOrderItem::where('shopping_order_id', $shopping_order->id)->where('row_id', $item->rowId)->count()) {
ShoppingOrderItem::create([
'shopping_order_id' => $shopping_order->id,
'row_id' => $item->rowId,
'row_id' => $item->rowId,
'product_id' => $item->id,
'qty' => $item->qty,
'price' => $item->price,
'price_net' => Yard::instance('shopping')->rowPriceNet($item, 3, '.', ''),
'tax_rate' => $item->taxRate,
'slug' => $item->options->slug
'slug' => $item->options->slug,
]);
}
}
return $shopping_order;
}
public function orderStatusSendMail(ShoppingOrder $shopping_order){
public function orderStatusSendMail(ShoppingOrder $shopping_order)
{
$bcc = [];
$user_mail = ($shopping_order->shopping_user && $shopping_order->shopping_user->member) ? $shopping_order->shopping_user->member->email : false;
if($shopping_order->mode === 'dev'){
if ($shopping_order->mode === 'dev') {
$bcc[] = config('app.checkout_test_mail');
}else{
} else {
$bcc[] = config('app.checkout_mail');
}
Mail::to($user_mail)->bcc($bcc)->send(new MailCheckout($shopping_order->txaction, $shopping_order, null, false, $shopping_order->mode));
}
}
}

103
app/Models/ApiOrderLog.php Normal file
View file

@ -0,0 +1,103 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* AP-13: Protokolleintrag einer Bestell-Übertragung über die Partnercenter-API (wp/order).
*
* payload['lines'] enthält je Position: article (WP-SKU), qty, price (Cent),
* product_id/product_name (Zuordnung im Partnercenter, null = nicht zugeordnet),
* messages (API-Rohmeldungen) und failed (Position wurde nicht übernommen).
*/
class ApiOrderLog extends Model
{
protected $fillable = [
'shopping_order_id',
'shopping_user_id',
'wp_order_number',
'endpoint',
'payload',
'has_errors',
'error_summary',
];
protected $casts = [
'payload' => 'array',
'has_errors' => 'bool',
];
public function shoppingOrder(): BelongsTo
{
return $this->belongsTo(ShoppingOrder::class, 'shopping_order_id');
}
public function shoppingUser(): BelongsTo
{
return $this->belongsTo(ShoppingUser::class, 'shopping_user_id');
}
/**
* Fehlgeschlagene Übertragung ohne verwertbare Positionen (Kunde unbekannt,
* Bestellung existiert bereits, Speicherfehler).
*
* @param mixed $rawOrder eingehender wp_order-Payload (String oder Array)
*/
public static function recordFailure(mixed $wpOrderNumber, ?ShoppingUser $shoppingUser, mixed $rawOrder, string $summary): self
{
return self::query()->create([
'shopping_user_id' => $shoppingUser?->id,
'wp_order_number' => is_numeric($wpOrderNumber) ? (int) $wpOrderNumber : null,
'endpoint' => 'order',
'payload' => ['raw' => is_string($rawOrder) ? $rawOrder : json_decode(json_encode($rawOrder), true)],
'has_errors' => true,
'error_summary' => $summary,
]);
}
/**
* Erfolgreich verarbeitete Übertragung: Positionen inkl. Zuordnungsergebnis protokollieren.
* Positionen ohne Produktzuordnung (SKU unbekannt / Parameter fehlen) markieren den Eintrag.
*
* @param array<int, object> $lines Zeilen aus prepareOrder() (article/qty/price/message)
*/
public static function recordTransfer(mixed $wpOrderNumber, ShoppingUser $shoppingUser, ?ShoppingOrder $shoppingOrder, array $lines): self
{
$payloadLines = [];
$failedCount = 0;
foreach ($lines as $line) {
$line = (object) $line;
$article = $line->article ?? null;
$product = $article !== null ? Product::whereWpNumber($article)->first() : null;
$failed = $product === null;
if ($failed) {
$failedCount++;
}
$payloadLines[] = [
'article' => $article,
'qty' => $line->qty ?? null,
'price' => $line->price ?? null,
'product_id' => $product?->id,
'product_name' => $product?->name,
'messages' => array_values((array) ($line->message ?? [])),
'failed' => $failed,
];
}
return self::query()->create([
'shopping_order_id' => $shoppingOrder?->id,
'shopping_user_id' => $shoppingUser->id,
'wp_order_number' => is_numeric($wpOrderNumber) ? (int) $wpOrderNumber : null,
'endpoint' => 'order',
'payload' => ['lines' => $payloadLines],
'has_errors' => $failedCount > 0,
'error_summary' => $failedCount > 0
? __(':failed von :total Positionen ohne Produktzuordnung', ['failed' => $failedCount, 'total' => count($payloadLines)])
: null,
]);
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-13: Protokoll aller über die Partnercenter-API eingehenden Bestell-Übertragungen
* (wp/order). Positionen inkl. SKU-Zuordnungsergebnis liegen als JSON im payload;
* fehlgeschlagene Zuordnungen markieren den Eintrag (has_errors).
*/
public function up(): void
{
Schema::create('api_order_logs', function (Blueprint $table) {
$table->id();
// shopping_orders.id / shopping_users.id sind historisch unsignedInteger (increments)
$table->unsignedInteger('shopping_order_id')->nullable();
$table->unsignedInteger('shopping_user_id')->nullable();
$table->unsignedBigInteger('wp_order_number')->nullable()->index();
$table->string('endpoint', 20)->default('order');
$table->json('payload')->nullable();
$table->boolean('has_errors')->default(false)->index();
$table->string('error_summary')->nullable();
$table->timestamps();
$table->foreign('shopping_order_id')->references('id')->on('shopping_orders')->nullOnDelete();
$table->foreign('shopping_user_id')->references('id')->on('shopping_users')->nullOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('api_order_logs');
}
};

View file

@ -0,0 +1,228 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.wawi-ui')
<div class="wawi-page">
<div class="wawi-page-head">
<div>
<h1 class="wawi-page-head__title">{{ __('API-Bestellungen') }}</h1>
<p class="wawi-page-head__subtitle">{{ __('Über die Partnercenter-API übertragene Shop-Bestellungen — Positionen ohne Produktzuordnung sind markiert') }}</p>
</div>
</div>
@if ($duplicateSkuProducts->isNotEmpty())
<div class="wawi-card mb-3" style="border-left: 4px solid #d63031;">
<div class="p-3">
<strong class="text-danger"><span class="fas fa-exclamation-triangle mr-1"></span>{{ __('Doppelte WP-Nummern (SKU) bei aktiven Produkten') }}</strong>
<p class="text-muted small mb-2">{{ __('Bei doppelten WP-Nummern ist nicht eindeutig, welches Produkt einer API-Position zugeordnet wird — bitte bereinigen.') }}</p>
<ul class="mb-0 small">
@foreach ($duplicateSkuProducts->groupBy('wp_number') as $wpNumber => $products)
<li>
<strong>{{ $wpNumber }}</strong>:
@foreach ($products as $product)
<a href="{{ route('admin_product_edit', $product->id) }}">{{ $product->name }}</a>@if (! $loop->last), @endif
@endforeach
</li>
@endforeach
</ul>
</div>
</div>
@endif
@if ($missingSkuProducts->isNotEmpty())
<div class="wawi-card mb-3">
<div class="p-3">
<details>
<summary class="text-muted">
{{ __(':n aktive Produkte ohne WP-Nummer (können per API nicht zugeordnet werden)', ['n' => $missingSkuProducts->count()]) }}
</summary>
<ul class="mb-0 mt-2 small">
@foreach ($missingSkuProducts as $product)
<li><a href="{{ route('admin_product_edit', $product->id) }}">{{ $product->name }}</a></li>
@endforeach
</ul>
</details>
</div>
</div>
@endif
<div class="wawi-card">
<div class="wawi-toolbar">
<label class="mb-0 small d-flex align-items-center" style="gap:.4rem;">
<input type="checkbox" id="api-log-only-errors">
{{ __('Nur Einträge mit Fehlern') }}
@if ($errorCount > 0)
<span class="wawi-pill wawi-pill--danger">{{ $errorCount }}</span>
@endif
</label>
</div>
<div class="table-responsive">
<table class="table wawi-table" id="api-log-table">
<thead>
<tr>
<th>{{ __('Eingegangen') }}</th>
<th>{{ __('WP-Bestellnr.') }}</th>
<th>{{ __('Kunde') }}</th>
<th>{{ __('Bestellung') }}</th>
<th class="text-right">{{ __('Positionen') }}</th>
<th>{{ __('Status') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse ($logs as $log)
@php
$lines = $log->payload['lines'] ?? [];
$customer = $log->shoppingUser
? trim(($log->shoppingUser->billing_firstname ?? '').' '.($log->shoppingUser->billing_lastname ?? ''))
: '';
@endphp
<tr class="api-log-row" data-errors="{{ $log->has_errors ? 1 : 0 }}">
<td>{{ $log->created_at->format('d.m.Y H:i') }}</td>
<td>{{ $log->wp_order_number ?? '—' }}</td>
<td>{{ $customer !== '' ? $customer : '—' }}</td>
<td>
@if ($log->shopping_order_id)
<a href="{{ route('admin_sales_detail', $log->shopping_order_id) }}">#{{ $log->shopping_order_id }}</a>
@else
<span class="text-muted">—</span>
@endif
</td>
<td class="text-right">{{ count($lines) ?: '—' }}</td>
<td>
@if ($log->has_errors)
<span class="wawi-pill wawi-pill--danger">{{ __('Fehler') }}</span>
@if ($log->error_summary)
<div class="small text-danger">{{ $log->error_summary }}</div>
@endif
@else
<span class="wawi-pill wawi-pill--ok">{{ __('OK') }}</span>
@endif
</td>
<td class="text-right">
<button type="button" class="btn btn-sm btn-outline-secondary js-api-log-view"
data-meta="{{ json_encode([
'received' => $log->created_at->format('d.m.Y H:i'),
'wp_order_number' => $log->wp_order_number,
'customer' => $customer,
'summary' => $log->error_summary,
'raw' => isset($log->payload['raw']) ? json_encode($log->payload['raw'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : null,
]) }}"
data-lines="{{ json_encode($lines) }}">
<span class="far fa-eye mr-1"></span>{{ __('Ansehen') }}
</button>
</td>
</tr>
@empty
<tr>
<td colspan="7">
<div class="wawi-empty">
<div><span class="fas fa-cloud-download-alt"></span></div>
{{ __('Noch keine Bestell-Übertragungen eingegangen.') }}
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if (($listLimit ?? null) !== null && $logs->count() >= $listLimit)
<p class="text-muted small px-3 pb-2 mb-0">
{{ __('Es werden die neuesten :n Übertragungen angezeigt.', ['n' => $listLimit]) }}
<a href="{{ route('admin.inventory.api-orders.index', ['all' => 1]) }}">{{ __('Alle anzeigen') }}</a>
</p>
@endif
</div>
<div class="modal fade" id="api-log-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ __('Inhalt der Bestell-Übertragung') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-2" id="api-log-modal-meta"></p>
<p class="small text-danger mb-2 d-none" id="api-log-modal-summary"></p>
<div class="table-responsive d-none" id="api-log-modal-lines-wrap">
<table class="table table-sm wawi-table mb-0">
<thead>
<tr>
<th>{{ __('WP-Nr. (SKU)') }}</th>
<th>{{ __('Produkt im Partnercenter') }}</th>
<th class="text-right">{{ __('Menge') }}</th>
<th class="text-right">{{ __('Preis (WP)') }}</th>
<th>{{ __('Meldung') }}</th>
</tr>
</thead>
<tbody id="api-log-modal-lines"></tbody>
</table>
</div>
<pre class="small bg-light p-2 d-none" id="api-log-modal-raw" style="max-height:300px;overflow:auto;"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{ __('Schließen') }}</button>
</div>
</div>
</div>
</div>
</div>
<script>
$(function () {
$('#api-log-only-errors').on('change', function () {
var only = $(this).is(':checked');
$('#api-log-table tbody tr.api-log-row').each(function () {
$(this).toggle(!only || $(this).data('errors') === 1);
});
});
function messageLabel(message) {
if (message === 'article not found') {
return '{{ __('Produkt nicht zugeordnet — WP-Nummer im Partnercenter unbekannt') }}';
}
if (message === 'article parameter is missing') {
return '{{ __('Position unvollständig (Artikel/Menge/Preis fehlt)') }}';
}
if (message.indexOf('different price:') === 0) {
var cents = parseInt(message.replace('different price:', '').trim(), 10);
var euro = isNaN(cents) ? '?' : (cents / 100).toFixed(2).replace('.', ',');
return '{{ __('Preis weicht ab — Preis im Partnercenter:') }} ' + euro + ' €';
}
return message;
}
$('.js-api-log-view').on('click', function () {
var meta = $(this).data('meta') || {};
var lines = $(this).data('lines') || [];
$('#api-log-modal-meta').text(
'{{ __('Eingegangen') }}: ' + (meta.received || '—')
+ ' · {{ __('WP-Bestellnr.') }}: ' + (meta.wp_order_number || '—')
+ (meta.customer ? ' · {{ __('Kunde') }}: ' + meta.customer : '')
);
$('#api-log-modal-summary').toggleClass('d-none', !meta.summary).text(meta.summary || '');
var $body = $('#api-log-modal-lines').empty();
$('#api-log-modal-lines-wrap').toggleClass('d-none', lines.length === 0);
$.each(lines, function (_, line) {
var $tr = $('<tr>').toggleClass('table-danger', !!line.failed);
$tr.append($('<td>').text(line.article !== null && line.article !== undefined ? line.article : '—'));
$tr.append($('<td>').text(line.product_name || '{{ __('— nicht zugeordnet —') }}')
.toggleClass('text-danger font-weight-bold', !line.product_id));
$tr.append($('<td class="text-right">').text(line.qty !== null && line.qty !== undefined ? line.qty : '—'));
var price = parseInt(line.price, 10);
$tr.append($('<td class="text-right">').text(isNaN(price) ? '—' : (price / 100).toFixed(2).replace('.', ',') + ' €'));
var messages = (line.messages || []).map(messageLabel).join('; ');
$tr.append($('<td>').text(messages).addClass(line.failed ? 'text-danger' : 'text-muted'));
$body.append($tr);
});
$('#api-log-modal-raw').toggleClass('d-none', !meta.raw).text(meta.raw || '');
$('#api-log-modal').modal('show');
});
});
</script>
@endsection

View file

@ -256,6 +256,12 @@
<div>{{ __('Ausgang / Ausschuss') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/inventory/api-orders*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.api-orders.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-cloud-download"></i>
<div>{{ __('API-Bestellungen') }}</div>
</a>
</li>
<li class="sidenav-item @if (Request::is('admin/inventory/productions*', 'admin/inventory/product-development*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-md-construct"></i>

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\Inventory\ApiOrderLogController as InventoryApiOrderLogController;
use App\Http\Controllers\Admin\Inventory\DeliveryTimeController as InventoryDeliveryTimeController;
use App\Http\Controllers\Admin\Inventory\DisposalReasonController as InventoryDisposalReasonController;
use App\Http\Controllers\Admin\Inventory\GeneralSettingController as InventoryGeneralSettingController;
@ -328,6 +329,7 @@ Route::domain(config('app.domain'))->group(function () {
Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']);
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
Route::get('product-development', [InventoryProductDevelopmentController::class, 'index'])->name('product-development');
Route::get('api-orders', [InventoryApiOrderLogController::class, 'index'])->name('api-orders.index');
});
});

View file

@ -0,0 +1,229 @@
<?php
use App\Models\ApiOrderLog;
use App\Models\Country;
use App\Models\Product;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Laravel\Passport\Passport;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function apilogCountry(): Country
{
return Country::query()->where('code', 'DE')->first() ?? Country::query()->forceCreate([
'code' => 'DE',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Germanija',
]);
}
function apilogMakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('apilog_', true).'@test.example',
'password' => bcrypt('password'),
]);
$account = UserAccount::query()->create([
'm_first_name' => 'Api',
'm_last_name' => 'Partner',
'country_id' => apilogCountry()->id,
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
'account_id' => $account->id,
])->save();
return $user->fresh();
}
function apilogMakeShoppingUser(int $wpOrderNumber): ShoppingUser
{
$country = apilogCountry();
$shipping = Shipping::query()->forceCreate(['name' => 'Standard']);
ShippingCountry::query()->forceCreate([
'shipping_id' => $shipping->id,
'country_id' => $country->id,
]);
return ShoppingUser::query()->forceCreate([
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_firstname' => 'Api',
'billing_lastname' => 'Kunde',
'wp_order_number' => $wpOrderNumber,
'member_id' => apilogMakeUser(0)->id,
'auth_user_id' => apilogMakeUser(0)->id,
]);
}
function apilogMakeProduct(string $name, int $wpNumber, string $price = '19,90'): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => true,
'is_set' => false,
'wp_number' => $wpNumber,
'price' => $price,
'tax' => '19',
'weight' => 100,
]);
}
test('uebertragung mit unbekannter sku wird protokolliert und markiert', function () {
Mail::fake();
Passport::actingAs(apilogMakeUser());
$product = apilogMakeProduct('Apilog Creme', 100);
$shoppingUser = apilogMakeShoppingUser(4711);
$response = $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [
'wp_order_number' => 4711,
'wp_order' => [
['article' => 100, 'qty' => 2, 'price' => 1990],
['article' => 999, 'qty' => 1, 'price' => 500],
],
]);
$response->assertOk()->assertJsonPath('success', true);
$log = ApiOrderLog::query()->sole();
expect($log->has_errors)->toBeTrue()
->and($log->error_summary)->toBe('1 von 2 Positionen ohne Produktzuordnung')
->and($log->wp_order_number)->toBe(4711)
->and($log->shopping_user_id)->toBe($shoppingUser->id)
->and($log->shopping_order_id)->not->toBeNull()
->and($log->payload['lines'])->toHaveCount(2)
->and($log->payload['lines'][0]['product_id'])->toBe($product->id)
->and($log->payload['lines'][0]['failed'])->toBeFalse()
->and($log->payload['lines'][1]['product_id'])->toBeNull()
->and($log->payload['lines'][1]['failed'])->toBeTrue()
->and($log->payload['lines'][1]['messages'])->toContain('article not found');
// Die nicht zugeordnete Position fehlt in der Bestellung (Bestand: kein Abzug möglich)
expect(ShoppingOrderItem::query()->where('shopping_order_id', $log->shopping_order_id)->count())->toBe(1);
});
test('fehlerfreie uebertragung wird als ok protokolliert', function () {
Mail::fake();
Passport::actingAs(apilogMakeUser());
apilogMakeProduct('Apilog Seife', 200);
apilogMakeShoppingUser(4712);
$this->postJson('https://api.'.config('app.api_domain').'/wp/order', [
'wp_order_number' => 4712,
'wp_order' => [
['article' => 200, 'qty' => 1, 'price' => 1990],
],
])->assertOk();
$log = ApiOrderLog::query()->sole();
expect($log->has_errors)->toBeFalse()
->and($log->error_summary)->toBeNull()
->and($log->shopping_order_id)->not->toBeNull();
});
test('bereits existierende bestellung wird als fehler protokolliert', function () {
Mail::fake();
Passport::actingAs(apilogMakeUser());
$shoppingUser = apilogMakeShoppingUser(4713);
ShoppingOrder::query()->forceCreate([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => apilogMakeUser(0)->id,
'country_id' => ShippingCountry::query()->first()->id,
'shipped' => 0,
]);
$this->postJson('https://api.'.config('app.api_domain').'/wp/order', [
'wp_order_number' => 4713,
'wp_order' => [['article' => 1, 'qty' => 1, 'price' => 100]],
])->assertStatus(400);
$log = ApiOrderLog::query()->sole();
expect($log->has_errors)->toBeTrue()
->and($log->error_summary)->toContain('existiert bereits');
});
test('unbekannte wp-bestellnummer wird protokolliert', function () {
Mail::fake();
Passport::actingAs(apilogMakeUser());
$this->postJson('https://api.'.config('app.api_domain').'/wp/order', [
'wp_order_number' => 9999,
'wp_order' => [['article' => 1, 'qty' => 1, 'price' => 100]],
])->assertStatus(400);
$log = ApiOrderLog::query()->sole();
expect($log->has_errors)->toBeTrue()
->and($log->shopping_user_id)->toBeNull()
->and($log->wp_order_number)->toBe(9999);
});
test('uebersicht rendert eintraege mit fehler-markierung', function () {
$shoppingUser = apilogMakeShoppingUser(4714);
ApiOrderLog::query()->create([
'shopping_user_id' => $shoppingUser->id,
'wp_order_number' => 4714,
'endpoint' => 'order',
'payload' => ['lines' => [['article' => 5, 'qty' => 1, 'price' => 100, 'product_id' => null, 'product_name' => null, 'messages' => ['article not found'], 'failed' => true]]],
'has_errors' => true,
'error_summary' => '1 von 1 Positionen ohne Produktzuordnung',
]);
$this->actingAs(apilogMakeUser(1))
->get(route('admin.inventory.api-orders.index'))
->assertOk()
->assertSee('API-Bestellungen')
->assertSee('4714')
->assertSee('1 von 1 Positionen ohne Produktzuordnung');
});
test('doppelte wp-nummern aktiver produkte werden gewarnt', function () {
apilogMakeProduct('Apilog Duplikat A', 300);
apilogMakeProduct('Apilog Duplikat B', 300);
$this->actingAs(apilogMakeUser(1))
->get(route('admin.inventory.api-orders.index'))
->assertOk()
->assertSee('Doppelte WP-Nummern')
->assertSee('Apilog Duplikat A')
->assertSee('Apilog Duplikat B');
});
test('produkte ohne wp-nummer werden gezaehlt', function () {
apilogMakeProduct('Apilog Mit Nummer', 400);
Product::query()->create([
'name' => 'Apilog Ohne Nummer',
'title' => 'Apilog Ohne Nummer',
'active' => true,
'is_set' => false,
]);
$this->actingAs(apilogMakeUser(1))
->get(route('admin.inventory.api-orders.index'))
->assertOk()
->assertSee('aktive Produkte ohne WP-Nummer', false)
->assertSee('Apilog Ohne Nummer');
});
test('ohne copyreader-rechte kein zugriff', function () {
$this->actingAs(apilogMakeUser(0))
->get(route('admin.inventory.api-orders.index'))
->assertRedirect();
});