Складской учет в интернет-магазине (на примере Yii2)

Обсуждаем, как правильно строить приложения
Ответить
undestroyer
Сообщения: 120
Зарегистрирован: 2014.01.06, 13:46

Складской учет в интернет-магазине (на примере Yii2)

Сообщение undestroyer »

Добрый день! После просмотра доклада Дмитрия Науменко "Рецепты для Yii2", решил отрефакторить интернет-магазин и привести там все в порядок.

Решил начать с простого складского учета. Есть склады, на них приходят поступления товаров и продаются товары. Сильно упрощенная схема ниже.

Изображение

Хочу сделать красивую группу моделей, поэтому выделил для них отдельный неймспейс \frontend\models\good

Теперь про бизнес-логику: ее надо в отдельные классы выносить. Вот тут я встал. Для фиксирования продажи менеджер использует сразу 2 модели (Sell - для выбора продавца, GoodSellForm - для ввода названия товара, цены и количества [менеджер может продать товар не со склада, тогда автоматически создается товар с указанным названием, дефолтными значениями и списывается со склада]).

Ограничение: если товар, указанный менеджером есть в каталоге, количество товара в продаже не может быть больше чем есть на складе.

У меня единственная мысль как сделать это более-менее правильно: внедрить эту проверку в правила валидации товаров модели GoodSellForm, но насколько это нормально - делать такую глубокую валидацию с привлечением сторонних моделей?

Сейчас этой "валидацией" занимается сам GoodSell в момент проведения продажи (продает все подряд, как только встречает ошибку - откатывает транзакцию). В таком случае не удобно возвращать ошибки наверх, в модель GoodSellForm, для отображения ошибки менеджеру.

Собственно вопрос еще раз, более конкретно: куда писать валидацию товаров в продаже и кто ей должен заниматся?
Аватара пользователя
ElisDN
Сообщения: 5845
Зарегистрирован: 2012.10.07, 10:24
Контактная информация:

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение ElisDN »

Выводите всё в сообщении над формой:

Код: Выделить всё

public function actionSell()
{
    $form = Yii::createObject(GoodSellForm::className());
    
    if ($form->load(Yii::$app->request->post()) && $form->validate()) {
        try {
            $this->sellService->sell($form);
            Yii::$app->session->setFlash('success', 'Продано.');
            return $this->redirect(['index']);
        } catch (\DomainException $e) {
            Yii::$app->session->setFlash('error', $e->getMessage());
        }
    }
    
    return $this->render('sell', [
        'form' => $form,
    ]);
} 

Код: Выделить всё

class SellService
{
    public function sell(GoodSellForm $form)
    {
        ...
        
        if ($form->amount > $amount = $this->warehouse->getCountForProduct($product->id)) {
            throw new \DomainException('Доступно только ' . $amount . ' единиц.');
        }
        
        ...
    }
} 
undestroyer
Сообщения: 120
Зарегистрирован: 2014.01.06, 13:46

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение undestroyer »

ElisDN писал(а):Выводите всё в сообщении над формой:
Не удобно для пользователя если в одной продаже было много товаров. Лучше если ошибка будет возле конкретного товара, по которому не сошлись остатки.

То есть бизнес-логика в сервисе планируется, но к этому времени нужно быть 100% уверенным что операция не заставит остаток идти в минус

И поясните пожалуйста строку

Код: Выделить всё

$this->sellService->sell($form); 
Каким образом произошло подключение sellService к контроллеру?
Аватара пользователя
SiZE
Сообщения: 2817
Зарегистрирован: 2011.09.21, 12:39
Откуда: Perm
Контактная информация:

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение SiZE »

ElisDN писал(а):

Код: Выделить всё

if ($form->load(Yii::$app->request->post()) && $form->validate()) 

Код: Выделить всё

if ($form->amount > $amount = $this->warehouse->getCountForProduct($product->id)) {
            throw new \DomainException('Доступно только ' . $amount . ' единиц.');
        } 
А почему бы это не сделать при валидации формы?
Аватара пользователя
ElisDN
Сообщения: 5845
Зарегистрирован: 2012.10.07, 10:24
Контактная информация:

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение ElisDN »

undestroyer писал(а):Не удобно для пользователя если в одной продаже было много товаров. Лучше если ошибка будет возле конкретного товара, по которому не сошлись остатки.
SiZE писал(а):А почему бы это не сделать при валидации формы?
Тогда валидируйте ещё и в форме в rules():

Код: Выделить всё

['amount', 'required'],
['amount', 'integer'],
['amount', function ($attribute) {
    if ($this->$attribute > $amount = $this->warehouse->getCountForProduct($product->id)) {
       $this->addError($attribute, 'Доступно только ' . $amount . ' единиц.');
    } 
}], 
undestroyer писал(а):Каким образом произошло подключение sellService к контроллеру?
Из DI-контейнера в конструктор прилетело:

Код: Выделить всё

class SellController extends Controller
{
    private $sellService;

    public function __construct($id, $module, SellService $sellService, $config = []) {
        $this->sellService = $sellService;
        parent::__construct($id, $module, $config);
    }
} 

Код: Выделить всё

class GoodSellForm extends Model
{
    private $warehause;

    public function __construct(Warehause $warehause, $config = []) {
        $this->warehause = $warehause;
        parent::__construct($config);
    }

    ...
} 
undestroyer
Сообщения: 120
Зарегистрирован: 2014.01.06, 13:46

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение undestroyer »

ElisDN писал(а):
undestroyer писал(а):Каким образом произошло подключение sellService к контроллеру?
Из DI-контейнера в конструктор прилетело:

Код: Выделить всё

class SellController extends Controller
{
    private $sellService;

    public function __construct($id, $module, SellService $sellService, $config = []) {
        $this->sellService = $sellService;
        parent::__construct($id, $module, $config);
    }
}

Код: Выделить всё

class GoodSellForm extends Model
{
    private $warehause;

    public function __construct(Warehause $warehause, $config = []) {
        $this->warehause = $warehause;
        parent::__construct($config);
    }

    ...
}

Спасибо, DI я как-то упустил из вида.

В GoodSellForm конструктор просит Warehouse. Warehouse - это AR-модель Склада из БД? То есть взаимодействие идет в порядке:
Форма товара в продажу -> Склад -> Товар на складе
Почему не сделать какой-то класс, назовем его "МенеджерСклада", который предоставляет интерфейс напрямую к товарам на складам? По-сути надо сделать набор методов в стиле:
  1. получитьОстатокТовараНаСкладе(Товар,Склад):integer
  2. изъятьТоварСоСклада(Товар,Склад,Количество):bool
  3. добавитьТоварНаСклад(Товар,Склад,Количество):bool
Потом, для операций бизнес-логики, делаем сервисы соответствующих операций, которые в своей работе используют этот менеджер. Взаимодействие уже будет в стиле:
Форма товара в продажу -> СервисПродажи -> МенеджерСклада -> Товар на складе
Этим я пытаюсь убрать прямую работу с моделью склада, которая по своей сути никакого отношения к операции не имеет. Алгоритм тогда будет таким:
  1. Контроллер загрузил данные формы и провел ее валидацию
  2. Контроллер передал набор данных в СервисПродажи
  3. СервисПродажи создает новую транзакцию
  4. СервисПродажи создает новую продажу
  5. СервисПродажи поочередно создает ТоварПродажи (модель с товаром, складом, ценой и количеством)
  6. СервисПродажи вызывает МенеджерСклада для уменьшения остатка товаров
  7. СервисПродажи завершает транзакцию, возвращает результат работы
  8. Контроллер выдает результат пользователю
Аватара пользователя
ElisDN
Сообщения: 5845
Зарегистрирован: 2012.10.07, 10:24
Контактная информация:

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение ElisDN »

undestroyer писал(а):В GoodSellForm конструктор просит Warehouse. Warehouse - это AR-модель Склада из БД?
Нет, AR через DI не грузят.
undestroyer писал(а):Почему не сделать какой-то класс, назовем его "МенеджерСклада", который предоставляет интерфейс напрямую к товарам на складам?
Да, Warehouse - это и есть "Склад".
undestroyer
Сообщения: 120
Зарегистрирован: 2014.01.06, 13:46

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение undestroyer »

ElisDN писал(а):
undestroyer писал(а):В GoodSellForm конструктор просит Warehouse. Warehouse - это AR-модель Склада из БД?
Нет, AR через DI не грузят.
undestroyer писал(а):Почему не сделать какой-то класс, назовем его "МенеджерСклада", который предоставляет интерфейс напрямую к товарам на складам?
Да, Warehouse - это и есть "Склад".
Спасибо, начинаю понимать :)

Вот есть у нас "Склад"/"МенеджерСклада", который добавляет/убирает товары со склада. У него есть 3 метода (добавь/убери/скажи сколько есть), которые предоставляют интерфейс для работы с товарами на этом складе. К нему обращаются МенеджерПродажи и МенеджерПоступления

Как сделать обработку ошибок?
Сценарий:
  1. Контроллер получил данные, загнал в форму, провалидировал - все ок
  2. МенеджерПродажи получил данные о новой продаже
  3. МенеджерПродажи вызвал операцию уменьшения остатка МенеджераСклада
  4. МенеджерСклада не нашел данных о нужном товаре
За время между 1м и 4м шагом, другая операция использовала нужные товары (Race Condition?). МенеджерСклада может и Exception выбросить, но это не красиво, в идеале надо ошибку передать наверх, вплоть до модели.

Как передать данные через слои МенеджерСклада -> МенеджерПродажи -> ФормаПродажи ?
Аватара пользователя
ElisDN
Сообщения: 5845
Зарегистрирован: 2012.10.07, 10:24
Контактная информация:

Re: Складской учет в интернет-магазине (на примере Yii2)

Сообщение ElisDN »

undestroyer писал(а):МенеджерСклада может и Exception выбросить, но это не красиво, в идеале надо ошибку передать наверх, вплоть до модели.
Всё как раз и должно в ядре исключения выбрасывать. Если нужно именно специфическую ошибку в конкретное поле поместить, то выделите эту ошибку в отдельный Exception:

Код: Выделить всё

class ProductAmountException extends \DomainException
{
    private $productId;

    public function __construct($productId, $message, Exception $previous = null) {
        parent::__construct($message, 0, $previous);
    }

    public function getProductId() {
        return $this->productId;
    }
} 
кидайте её из склада:

Код: Выделить всё

throw new ProductAmountException($productId, 'Доступно только ' . $amount . ' единиц.'); 
и помещайте в нужную форму:

Код: Выделить всё

try {
    $this->sellService->sell($form);
    Yii::$app->session->setFlash('success', 'Продано.');
    return $this->redirect(['index']);
} catch (ProductAmountException $e) {
    $form->getProductFormFor($e->getProductId())->addError('amount', $e->getMessage());
} catch (\DomainException $e) {
    Yii::$app->session->setFlash('error', $e->getMessage());
} 
Ответить