1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

(merge) Merge branch 'product-store_stocks' into product-store

This commit is contained in:
Sylvain 2022-09-14 15:15:47 +02:00
commit c4baf89c54
33 changed files with 640 additions and 303 deletions

View File

@ -6,6 +6,8 @@ class API::ProductsController < API::ApiController
before_action :authenticate_user!, except: %i[index show]
before_action :set_product, only: %i[update destroy]
MOVEMENTS_PER_PAGE = 10
def index
@products = ProductService.list(params)
@pages = ProductService.pages(params) if params[:page].present?
@ -17,8 +19,7 @@ class API::ProductsController < API::ApiController
def create
authorize Product
@product = Product.new(product_params)
@product.amount = ProductService.amount_multiplied_by_hundred(@product.amount)
@product = ProductService.create(product_params, params[:product][:product_stock_movements_attributes])
if @product.save
render status: :created
else
@ -29,9 +30,8 @@ class API::ProductsController < API::ApiController
def update
authorize @product
product_parameters = product_params
product_parameters[:amount] = ProductService.amount_multiplied_by_hundred(product_parameters[:amount])
if @product.update(product_parameters)
@product = ProductService.update(@product, product_params, params[:product][:product_stock_movements_attributes])
if @product.save
render status: :ok
else
render json: @product.errors.full_messages, status: :unprocessable_entity
@ -44,6 +44,11 @@ class API::ProductsController < API::ApiController
head :no_content
end
def stock_movements
authorize Product
@movements = ProductStockMovement.where(product_id: params[:id]).order(date: :desc).page(params[:page]).per(MOVEMENTS_PER_PAGE)
end
private
def set_product
@ -56,7 +61,6 @@ class API::ProductsController < API::ApiController
:low_stock_alert, :low_stock_threshold,
machine_ids: [],
product_files_attributes: %i[id attachment _destroy],
product_images_attributes: %i[id attachment is_main _destroy],
product_stock_movements_attributes: %i[id quantity reason stock_type _destroy])
product_images_attributes: %i[id attachment is_main _destroy])
end
end

View File

@ -1,7 +1,7 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { Product, ProductIndexFilter, ProductsIndex } from '../models/product';
import { Product, ProductIndexFilter, ProductsIndex, ProductStockMovement } from '../models/product';
import ApiLib from '../lib/api';
export default class ProductAPI {
@ -89,4 +89,9 @@ export default class ProductAPI {
const res: AxiosResponse<void> = await apiClient.delete(`/api/products/${productId}`);
return res?.data;
}
static async stockMovements (productId: number, page = 1): Promise<Array<ProductStockMovement>> {
const res: AxiosResponse<Array<ProductStockMovement>> = await apiClient.get(`/api/products/${productId}/stock_movements?page=${page}`);
return res?.data;
}
}

View File

@ -8,7 +8,7 @@ export default class SettingAPI {
return res?.data?.setting;
}
static async query (names: Array<SettingName>): Promise<Map<SettingName, string>> {
static async query (names: readonly SettingName[]): Promise<Map<SettingName, string>> {
const params = new URLSearchParams();
params.append('names', `['${names.join("','")}']`);
@ -32,7 +32,7 @@ export default class SettingAPI {
return res?.data?.isPresent;
}
private static toSettingsMap (names: Array<SettingName>, data: Record<string, string|null>): Map<SettingName, string> {
private static toSettingsMap (names: readonly SettingName[], data: Record<string, string|null>): Map<SettingName, string> {
const map = new Map();
names.forEach(name => {
map.set(name, data[name] || '');

View File

@ -23,6 +23,7 @@ interface FabModalProps {
customHeader?: ReactNode,
customFooter?: ReactNode,
onConfirm?: (event: BaseSyntheticEvent) => void,
onClose?: (event: BaseSyntheticEvent) => void,
preventConfirm?: boolean,
onCreation?: () => void,
onConfirmSendFormId?: string,
@ -31,7 +32,7 @@ interface FabModalProps {
/**
* This component is a template for a modal dialog that wraps the application style
*/
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, onClose, preventConfirm, onCreation, onConfirmSendFormId }) => {
const { t } = useTranslation('shared');
useEffect(() => {
@ -40,12 +41,20 @@ export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal,
}
}, [isOpen]);
/**
* Callback triggered when the user request to close the modal without confirming.
*/
const handleClose = (event) => {
if (typeof onClose === 'function') onClose(event);
toggleModal();
};
return (
<Modal isOpen={isOpen}
className={`fab-modal fab-modal-${width} ${className || ''}`}
overlayClassName="fab-modal-overlay"
onRequestClose={toggleModal}>
{closeButton && <FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.fab_modal.close')}</FabButton>}
onRequestClose={handleClose}>
{closeButton && <FabButton className="modal-btn--close" onClick={handleClose}>{t('app.shared.fab_modal.close')}</FabButton>}
<div className="fab-modal-header">
{!customHeader && <h1>{ title }</h1>}
{customHeader && customHeader}

View File

@ -18,12 +18,13 @@ interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldV
placeholder?: string,
step?: number | 'any',
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
nullable?: boolean
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps<TFieldValues, TInputType>) => {
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept, nullable = false }: FormInputProps<TFieldValues, TInputType>) => {
/**
* Debounced (ie. temporised) version of the 'on change' callback.
*/
@ -57,8 +58,8 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
<input id={id}
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsNumber: type === 'number',
valueAsDate: type === 'date',
setValueAs: v => (v === null && nullable) ? null : (type === 'number' ? parseInt(v, 10) : v),
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); }
})}

View File

@ -0,0 +1,112 @@
import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { UIRouter } from '@uirouter/angularjs';
import { FormState } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FabModal } from '../base/fab-modal';
import Deferred from '../../lib/deferred';
import { useTranslation } from 'react-i18next';
interface UnsavedFormAlertProps<TFieldValues> {
uiRouter: UIRouter,
formState: FormState<TFieldValues>,
}
/**
* Alert the user about unsaved changes in the given form, before leaving the current page.
* This component is highly dependent of these external libraries:
* - [react-hook-form](https://react-hook-form.com/)
* - [ui-router](https://ui-router.github.io/)
*/
export const UnsavedFormAlert = <TFieldValues extends FieldValues>({ uiRouter, formState, children }: PropsWithChildren<UnsavedFormAlertProps<TFieldValues>>) => {
const { t } = useTranslation('shared');
const [showAlertModal, setShowAlertModal] = useState<boolean>(false);
const [promise, setPromise] = useState<Deferred<boolean>>(null);
const [dirty, setDirty] = useState<boolean>(formState.isDirty);
useEffect(() => {
const submitStatus = (!formState.isSubmitting && (!formState.isSubmitted || !formState.isSubmitSuccessful));
setDirty(submitStatus && Object.keys(formState.dirtyFields).length > 0);
}, [formState]);
/**
* Check if the current form is dirty. If so, show the confirmation modal and return a promise
*/
const alertOnDirtyForm = (isDirty: boolean): Promise<boolean>|void => {
if (isDirty) {
toggleAlertModal();
const userChoicePromise = new Deferred<boolean>();
setPromise(userChoicePromise);
return userChoicePromise.promise;
}
};
// memoised version of the alertOnDirtyForm function, will be updated only when the form becames dirty
const alertDirty = useCallback<() => Promise<boolean>|void>(() => alertOnDirtyForm(dirty), [dirty]);
// we should place this useEffect after the useCallback declaration (because it's a scoped variable)
useEffect(() => {
const { transitionService, globals: { current } } = uiRouter;
const deregisters = transitionService.onBefore({ from: current.name }, alertDirty);
return () => {
deregisters();
};
}, [alertDirty]);
/**
* When the user tries to close the current page (tab/window), we alert him about unsaved changes
*/
const alertOnExit = (event: BeforeUnloadEvent, isDirty: boolean) => {
if (isDirty) {
event.preventDefault();
event.returnValue = '';
}
};
// memoised version of the alertOnExit function, will be updated only when the form becames dirty
const alertExit = useCallback<(event: BeforeUnloadEvent) => void>((event) => alertOnExit(event, dirty), [dirty]);
// we should place this useEffect after the useCallback declaration (because it's a scoped variable)
useEffect(() => {
window.addEventListener('beforeunload', alertExit);
return () => {
window.removeEventListener('beforeunload', alertExit);
};
}, [alertExit]);
/**
* Hide/show the alert modal "you have some unsaved content, are you sure you want to leave?"
*/
const toggleAlertModal = () => {
setShowAlertModal(!showAlertModal);
};
/**
* Callback triggered when the user has choosen: continue and exit
*/
const handleConfirmation = () => {
promise.resolve(true);
};
/**
* Callback triggered when the user has choosen: cancel and stay
*/
const handleCancel = () => {
promise.resolve(false);
};
return (
<div className="unsaved-form-alert">
{children}
<FabModal isOpen={showAlertModal}
toggleModal={toggleAlertModal}
confirmButton={t('app.shared.unsaved_form_alert.confirmation_button')}
title={t('app.shared.unsaved_form_alert.modal_title')}
onConfirm={handleConfirmation}
onClose={handleCancel}
closeButton>
{t('app.shared.unsaved_form_alert.confirmation_message')}
</FabModal>
</div>
);
};

View File

@ -55,7 +55,7 @@ const ProductCategories: React.FC<ProductCategoriesProps> = ({ onSuccess, onErro
*/
const refreshCategories = () => {
ProductCategoryAPI.index().then(data => {
setProductCategories(new ProductLib().sortCategories(data));
setProductCategories(ProductLib.sortCategories(data));
}).catch((error) => onError(error));
};

View File

@ -6,6 +6,7 @@ import { IApplication } from '../../models/application';
import { ProductForm } from './product-form';
import { Product } from '../../models/product';
import ProductAPI from '../../api/product';
import { UIRouter } from '@uirouter/angularjs';
declare const Application: IApplication;
@ -13,12 +14,13 @@ interface EditProductProps {
productId: number,
onSuccess: (message: string) => void,
onError: (message: string) => void,
uiRouter: UIRouter
}
/**
* This component show edit product form
*/
const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError, uiRouter }) => {
const { t } = useTranslation('admin');
const [product, setProduct] = useState<Product>();
@ -40,19 +42,23 @@ const EditProduct: React.FC<EditProductProps> = ({ productId, onSuccess, onError
if (product) {
return (
<div className="edit-product">
<ProductForm product={product} title={product.name} onSuccess={saveProductSuccess} onError={onError} />
<ProductForm product={product}
title={product.name}
onSuccess={saveProductSuccess}
onError={onError}
uiRouter={uiRouter} />
</div>
);
}
return null;
};
const EditProductWrapper: React.FC<EditProductProps> = ({ productId, onSuccess, onError }) => {
const EditProductWrapper: React.FC<EditProductProps> = (props) => {
return (
<Loader>
<EditProduct productId={productId} onSuccess={onSuccess} onError={onError} />
<EditProduct {...props} />
</Loader>
);
};
Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError']));
Application.Components.component('editProduct', react2angular(EditProductWrapper, ['productId', 'onSuccess', 'onError', 'uiRouter']));

View File

@ -4,18 +4,20 @@ import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { ProductForm } from './product-form';
import { UIRouter } from '@uirouter/angularjs';
declare const Application: IApplication;
interface NewProductProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
uiRouter: UIRouter,
}
/**
* This component show new product form
*/
const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError, uiRouter }) => {
const { t } = useTranslation('admin');
const product = {
@ -46,17 +48,21 @@ const NewProduct: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
return (
<div className="new-product">
<ProductForm product={product} title={t('app.admin.store.new_product.add_a_new_product')} onSuccess={saveProductSuccess} onError={onError} />
<ProductForm product={product}
title={t('app.admin.store.new_product.add_a_new_product')}
onSuccess={saveProductSuccess}
onError={onError}
uiRouter={uiRouter} />
</div>
);
};
const NewProductWrapper: React.FC<NewProductProps> = ({ onSuccess, onError }) => {
const NewProductWrapper: React.FC<NewProductProps> = (props) => {
return (
<Loader>
<NewProduct onSuccess={onSuccess} onError={onError} />
<NewProduct {...props} />
</Loader>
);
};
Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError']));
Application.Components.component('newProduct', react2angular(NewProductWrapper, ['onSuccess', 'onError', 'uiRouter']));

View File

@ -20,12 +20,15 @@ import ProductAPI from '../../api/product';
import { Plus } from 'phosphor-react';
import { ProductStockForm } from './product-stock-form';
import ProductLib from '../../lib/product';
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
import { UIRouter } from '@uirouter/angularjs';
interface ProductFormProps {
product: Product,
title: string,
onSuccess: (product: Product) => void,
onError: (message: string) => void,
uiRouter: UIRouter
}
/**
@ -42,7 +45,7 @@ type checklistOption = { value: number, label: string };
/**
* Form component to create or update a product
*/
export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSuccess, onError }) => {
export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSuccess, onError, uiRouter }) => {
const { t } = useTranslation('admin');
const { handleSubmit, register, control, formState, setValue, reset } = useForm<Product>({ defaultValues: { ...product } });
@ -54,7 +57,7 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
useEffect(() => {
ProductCategoryAPI.index().then(data => {
setProductCategories(buildSelectOptions(new ProductLib().sortCategories(data)));
setProductCategories(buildSelectOptions(ProductLib.sortCategories(data)));
}).catch(onError);
MachineAPI.index({ disabled: false }).then(data => {
setMachines(buildChecklistOptions(data));
@ -225,12 +228,13 @@ export const ProductForm: React.FC<ProductFormProps> = ({ product, title, onSucc
</div>
</header>
<form className="product-form" onSubmit={handleSubmit(onSubmit)}>
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
<div className='tabs'>
<p className={!stockTab ? 'is-active' : ''} onClick={() => setStockTab(false)}>{t('app.admin.store.product_form.product_parameters')}</p>
<p className={stockTab ? 'is-active' : ''} onClick={() => setStockTab(true)}>{t('app.admin.store.product_form.stock_management')}</p>
</div>
{stockTab
? <ProductStockForm product={product} register={register} control={control} formState={formState} onError={onError} onSuccess={onSuccess} />
? <ProductStockForm currentFormValues={output as Product} register={register} control={control} formState={formState} setValue={setValue} onError={onError} onSuccess={onSuccess} />
: <section>
<div className="subgrid">
<FormInput id="name"

View File

@ -1,38 +1,51 @@
import React, { useState } from 'react';
import { Product } from '../../models/product';
import { UseFormRegister } from 'react-hook-form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
import React, { useEffect, useState } from 'react';
import Select from 'react-select';
import { PencilSimple, X } from 'phosphor-react';
import { useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { useTranslation } from 'react-i18next';
import { Product, ProductStockMovement, StockMovementReason, StockType } from '../../models/product';
import { HtmlTranslate } from '../base/html-translate';
import { FormSwitch } from '../form/form-switch';
import { FormInput } from '../form/form-input';
import Select from 'react-select';
import { FabAlert } from '../base/fab-alert';
import { FabButton } from '../base/fab-button';
import { PencilSimple } from 'phosphor-react';
import { FabModal, ModalSize } from '../base/fab-modal';
import { ProductStockModal } from './product-stock-modal';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FabStateLabel } from '../base/fab-state-label';
import ProductAPI from '../../api/product';
import FormatLib from '../../lib/format';
import ProductLib from '../../lib/product';
interface ProductStockFormProps<TFieldValues, TContext extends object> {
product: Product,
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
formState: FormState<TFieldValues>,
interface ProductStockFormProps<TContext extends object> {
currentFormValues: Product,
register: UseFormRegister<Product>,
control: Control<Product, TContext>,
formState: FormState<Product>,
setValue: UseFormSetValue<Product>,
onSuccess: (product: Product) => void,
onError: (message: string) => void,
}
const DEFAULT_LOW_STOCK_THRESHOLD = 30;
/**
* Form tab to manage a product's stock
*/
export const ProductStockForm = <TFieldValues extends FieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockFormProps<TFieldValues, TContext>) => {
export const ProductStockForm = <TContext extends object> ({ currentFormValues, register, control, formState, setValue, onError }: ProductStockFormProps<TContext>) => {
const { t } = useTranslation('admin');
const [activeThreshold, setActiveThreshold] = useState<boolean>(false);
// is the modal open?
const [activeThreshold, setActiveThreshold] = useState<boolean>(currentFormValues.low_stock_threshold != null);
// is the update stock modal open?
const [isOpen, setIsOpen] = useState<boolean>(false);
const [stockMovements, setStockMovements] = useState<Array<ProductStockMovement>>([]);
const { fields, append, remove } = useFieldArray({ control, name: 'product_stock_movements_attributes' });
useEffect(() => {
if (!currentFormValues?.id) return;
ProductAPI.stockMovements(currentFormValues.id).then(setStockMovements).catch(onError);
}, []);
// Styles the React-select component
const customStyles = {
@ -47,41 +60,38 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
})
};
type selectOption = { value: number, label: string };
type reasonSelectOption = { value: StockMovementReason, label: string };
/**
* Creates sorting options to the react-select format
*/
const buildEventsOptions = (): Array<selectOption> => {
return [
{ value: 0, label: t('app.admin.store.product_stock_form.events.inward_stock') },
{ value: 1, label: t('app.admin.store.product_stock_form.events.returned') },
{ value: 2, label: t('app.admin.store.product_stock_form.events.canceled') },
{ value: 3, label: t('app.admin.store.product_stock_form.events.sold') },
{ value: 4, label: t('app.admin.store.product_stock_form.events.missing') },
{ value: 5, label: t('app.admin.store.product_stock_form.events.damaged') }
];
const buildReasonsOptions = (): Array<reasonSelectOption> => {
return (['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'sold', 'missing', 'damaged'] as Array<StockMovementReason>).map(key => {
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
});
};
type typeSelectOption = { value: StockType, label: string };
/**
* Creates sorting options to the react-select format
*/
const buildStocksOptions = (): Array<selectOption> => {
const buildStocksOptions = (): Array<typeSelectOption> => {
return [
{ value: 0, label: t('app.admin.store.product_stock_form.internal') },
{ value: 1, label: t('app.admin.store.product_stock_form.external') },
{ value: 2, label: t('app.admin.store.product_stock_form.all') }
{ value: 'internal', label: t('app.admin.store.product_stock_form.internal') },
{ value: 'external', label: t('app.admin.store.product_stock_form.external') },
{ value: 'all', label: t('app.admin.store.product_stock_form.all') }
];
};
/**
* On events option change
*/
const eventsOptionsChange = (evt: selectOption) => {
const eventsOptionsChange = (evt: reasonSelectOption) => {
console.log('Event option:', evt);
};
/**
* On stocks option change
*/
const stocksOptionsChange = (evt: selectOption) => {
const stocksOptionsChange = (evt: typeSelectOption) => {
console.log('Stocks option:', evt);
};
@ -90,38 +100,87 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
*/
const toggleStockThreshold = (checked: boolean) => {
setActiveThreshold(checked);
setValue(
'low_stock_threshold',
(checked ? DEFAULT_LOW_STOCK_THRESHOLD : null)
);
};
/**
* Opens/closes the product category modal
* Opens/closes the product stock edition modal
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Toggle stock threshold alert
* Triggered when a new product stock movement was added
*/
const toggleStockThresholdAlert = (checked: boolean) => {
console.log('Low stock notification:', checked);
const onNewStockMovement = (movement): void => {
append({ ...movement });
};
/**
* Return the data of the update of the stock for the current product
*/
const lastStockUpdate = () => {
if (stockMovements[0]) {
return stockMovements[0].date;
} else {
return currentFormValues?.created_at || new Date();
}
};
return (
<section className='product-stock-form'>
<h4>Stock à jour <span>00/00/0000 - 00H30</span></h4>
<h4>{t('app.admin.store.product_stock_form.stock_up_to_date')}&nbsp;
<span>{t('app.admin.store.product_stock_form.date_time', {
DATE: FormatLib.date(lastStockUpdate()),
TIME: FormatLib.time((lastStockUpdate()))
})}</span>
</h4>
<div></div>
<div className="stock-item">
<p className='title'>Product name</p>
<p className='title'>{currentFormValues?.name}</p>
<div className="group">
<span>{t('app.admin.store.product_stock_form.internal')}</span>
<p>00</p>
<p>{currentFormValues?.stock?.internal}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.external')}</span>
<p>000</p>
<p>{currentFormValues?.stock?.external}</p>
</div>
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
</div>
{fields.length > 0 && <div className="ongoing-stocks">
<span className="title">{t('app.admin.store.product_stock_form.ongoing_operations')}</span>
<span className="save-notice">{t('app.admin.store.product_stock_form.save_reminder')}</span>
{fields.map((newMovement, index) => (
<div key={index} className="unsaved-stock-movement stock-item">
<div className="group">
<p>{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(newMovement.reason)}`)}</p>
</div>
<div className="group">
<span>{t(`app.admin.store.product_stock_form.${newMovement.stock_type}`)}</span>
<p>{ProductLib.absoluteStockMovement(newMovement.quantity, newMovement.reason)}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.reason')}</span>
<p>{t(ProductLib.stockMovementReasonTrKey(newMovement.reason))}</p>
</div>
<p className="cancel-action" onClick={() => remove(index)}>
{t('app.admin.store.product_stock_form.cancel')}
<X size={20} />
</p>
<FormInput id={`product_stock_movements_attributes.${index}.stock_type`} register={register}
type="hidden" />
<FormInput id={`product_stock_movements_attributes.${index}.quantity`} register={register} type="hidden" />
<FormInput id={`product_stock_movements_attributes.${index}.reason`} register={register} type="hidden" />
</div>
))}
</div>}
<hr />
<div className="threshold-data">
@ -139,19 +198,18 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
{activeThreshold && <>
<FabStateLabel>{t('app.admin.store.product_stock_form.low_stock')}</FabStateLabel>
<div className="threshold-data-content">
<FormInput id="threshold"
type="number"
register={register}
rules={{ required: true, min: 1 }}
step={1}
formState={formState}
label={t('app.admin.store.product_stock_form.threshold_level')} />
<FormSwitch control={control}
id="threshold_alert"
formState={formState}
label={t('app.admin.store.product_stock_form.threshold_alert')}
defaultValue={activeThreshold}
onChange={toggleStockThresholdAlert} />
<FormInput id="low_stock_threshold"
type="number"
register={register}
rules={{ required: activeThreshold, min: 1 }}
step={1}
formState={formState}
nullable
label={t('app.admin.store.product_stock_form.threshold_level')} />
<FormSwitch control={control}
id="low_stock_alert"
formState={formState}
label={t('app.admin.store.product_stock_form.threshold_alert')} />
</div>
</>}
</div>
@ -163,7 +221,7 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
<div className='sort-events'>
<p>{t('app.admin.store.product_stock_form.event_type')}</p>
<Select
options={buildEventsOptions()}
options={buildReasonsOptions()}
onChange={evt => eventsOptionsChange(evt)}
styles={customStyles}
/>
@ -177,33 +235,29 @@ export const ProductStockForm = <TFieldValues extends FieldValues, TContext exte
/>
</div>
</div>
<div className="stock-history">
{stockMovements.map(movement => <div className="stock-history" key={movement.id}>
<div className="stock-item">
<p className='title'>Product name</p>
<p>00/00/0000</p>
<p className='title'>{currentFormValues.name}</p>
<p>{FormatLib.date(movement.date)}</p>
<div className="group">
<span>[stock type]</span>
<p>00</p>
<span>{t(`app.admin.store.product_stock_form.${movement.stock_type}`)}</span>
<p>{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.event_type')}</span>
<p>[event type]</p>
<span>{t('app.admin.store.product_stock_form.reason')}</span>
<p>{t(ProductLib.stockMovementReasonTrKey(movement.reason))}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.stock_level')}</span>
<p>000</p>
<span>{t('app.admin.store.product_stock_form.remaining_stock')}</span>
<p>{movement.remaining_stock}</p>
</div>
</div>
</div>
</div>)}
</div>
<FabModal title={t('app.admin.store.product_stock_form.modal_title')}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton>
<ProductStockModal product={product} register={register} control={control} formState={formState} onError={onError} onSuccess={onSuccess} />
</FabModal>
<ProductStockModal onError={onError}
onSuccess={onNewStockMovement}
isOpen={isOpen}
toggleModal={toggleModal} />
</section>
);
};

View File

@ -1,22 +1,27 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Product } from '../../models/product';
import { UseFormRegister } from 'react-hook-form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
import {
ProductStockMovement,
stockMovementInReasons,
stockMovementOutReasons,
StockMovementReason,
StockType
} from '../../models/product';
import { FormSelect } from '../form/form-select';
import { FormInput } from '../form/form-input';
import { FabButton } from '../base/fab-button';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FabModal, ModalSize } from '../base/fab-modal';
import { useForm } from 'react-hook-form';
import ProductLib from '../../lib/product';
type selectOption = { value: number, label: string };
type reasonSelectOption = { value: StockMovementReason, label: string };
type typeSelectOption = { value: StockType, label: string };
interface ProductStockModalProps<TFieldValues, TContext extends object> {
product: Product,
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
formState: FormState<TFieldValues>,
onSuccess: (product: Product) => void,
onError: (message: string) => void
interface ProductStockModalProps {
onSuccess: (movement: ProductStockMovement) => void,
onError: (message: string) => void,
isOpen: boolean,
toggleModal: () => void,
}
/**
@ -24,11 +29,13 @@ interface ProductStockModalProps<TFieldValues, TContext extends object> {
*/
// TODO: delete next eslint disable
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const ProductStockModal = <TFieldValues extends FieldValues, TContext extends object> ({ product, register, control, formState, onError, onSuccess }: ProductStockModalProps<TFieldValues, TContext>) => {
export const ProductStockModal: React.FC<ProductStockModalProps> = ({ onError, onSuccess, isOpen, toggleModal }) => {
const { t } = useTranslation('admin');
const [movement, setMovement] = useState<'in' | 'out'>('in');
const { handleSubmit, register, control, formState } = useForm<ProductStockMovement>();
/**
* Toggle between adding or removing product from stock
*/
@ -37,63 +44,75 @@ export const ProductStockModal = <TFieldValues extends FieldValues, TContext ext
setMovement(type);
};
/**
* Callback triggered when the user validates the new stock movement.
* We do not use handleSubmit() directly to prevent the propagaion of the "submit" event to the parent form
*/
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
if (event) {
event.stopPropagation();
event.preventDefault();
}
return handleSubmit((data: ProductStockMovement) => {
onSuccess(data);
toggleModal();
})(event);
};
/**
* Creates sorting options to the react-select format
*/
const buildEventsOptions = (): Array<selectOption> => {
let options = [];
movement === 'in'
? options = [
{ value: 0, label: t('app.admin.store.product_stock_modal.events.inward_stock') },
{ value: 1, label: t('app.admin.store.product_stock_modal.events.returned') },
{ value: 2, label: t('app.admin.store.product_stock_modal.events.canceled') }
]
: options = [
{ value: 0, label: t('app.admin.store.product_stock_modal.events.sold') },
{ value: 1, label: t('app.admin.store.product_stock_modal.events.missing') },
{ value: 2, label: t('app.admin.store.product_stock_modal.events.damaged') }
];
return options;
const buildEventsOptions = (): Array<reasonSelectOption> => {
return (movement === 'in' ? stockMovementInReasons : stockMovementOutReasons).map(key => {
return { value: key, label: t(ProductLib.stockMovementReasonTrKey(key)) };
});
};
/**
* Creates sorting options to the react-select format
*/
const buildStocksOptions = (): Array<selectOption> => {
const buildStocksOptions = (): Array<typeSelectOption> => {
return [
{ value: 0, label: t('app.admin.store.product_stock_modal.internal') },
{ value: 1, label: t('app.admin.store.product_stock_modal.external') }
{ value: 'internal', label: t('app.admin.store.product_stock_modal.internal') },
{ value: 'external', label: t('app.admin.store.product_stock_modal.external') }
];
};
return (
<form className='product-stock-modal'>
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
<div className="movement">
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
{t('app.admin.store.product_stock_modal.addition')}
</button>
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
{t('app.admin.store.product_stock_modal.withdrawal')}
</button>
</div>
<FormSelect options={buildStocksOptions()}
control={control}
id="updated_stock_type"
formState={formState}
label={t('app.admin.store.product_stock_modal.stocks')} />
<FormInput id="updated_stock_quantity"
type="number"
register={register}
rules={{ required: true, min: 1 }}
step={1}
formState={formState}
label={t('app.admin.store.product_stock_modal.quantity')} />
<FormSelect options={buildEventsOptions()}
control={control}
id="updated_stock_event"
formState={formState}
label={t('app.admin.store.product_stock_modal.event_type')} />
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
</form>
<FabModal title={t('app.admin.store.product_stock_modal.modal_title')}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
className="product-stock-modal"
closeButton>
<form onSubmit={onSubmit}>
<p className='subtitle'>{t('app.admin.store.product_stock_modal.new_event')}</p>
<div className="movement">
<button onClick={(evt) => toggleMovementType(evt, 'in')} className={movement === 'in' ? 'is-active' : ''}>
{t('app.admin.store.product_stock_modal.addition')}
</button>
<button onClick={(evt) => toggleMovementType(evt, 'out')} className={movement === 'out' ? 'is-active' : ''}>
{t('app.admin.store.product_stock_modal.withdrawal')}
</button>
</div>
<FormSelect options={buildStocksOptions()}
control={control}
id="stock_type"
formState={formState}
label={t('app.admin.store.product_stock_modal.stocks')} />
<FormInput id="quantity"
type="number"
register={register}
rules={{ required: true, min: 1 }}
step={1}
formState={formState}
label={t('app.admin.store.product_stock_modal.quantity')} />
<FormSelect options={buildEventsOptions()}
control={control}
id="reason"
formState={formState}
label={t('app.admin.store.product_stock_modal.reason_type')} />
<FabButton type='submit'>{t('app.admin.store.product_stock_modal.update_stock')} </FabButton>
</form>
</FabModal>
);
};

View File

@ -54,7 +54,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
});
ProductCategoryAPI.index().then(data => {
setProductCategories(new ProductLib().sortCategories(data));
setProductCategories(ProductLib.sortCategories(data));
}).catch(onError);
MachineAPI.index({ disabled: false }).then(data => {

View File

@ -4,11 +4,14 @@
*/
'use strict';
Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$',
function ($scope, CSRF, growl, $state, $transition$) {
Application.Controllers.controller('AdminStoreProductController', ['$scope', 'CSRF', 'growl', '$state', '$transition$', '$uiRouter',
function ($scope, CSRF, growl, $state, $transition$, $uiRouter) {
/* PUBLIC SCOPE */
$scope.productId = $transition$.params().id;
// the following item is used by the UnsavedFormAlert component to detect a page change
$scope.uiRouter = $uiRouter;
/**
* Callback triggered in case of error
*/

View File

@ -0,0 +1,22 @@
// This is a kind of promise you can resolve from outside the function callback.
// Credits to https://stackoverflow.com/a/71158892/1039377
export default class Deferred<T> {
public readonly promise: Promise<T>;
private resolveFn!: (value: T | PromiseLike<T>) => void;
private rejectFn!: (reason?: unknown) => void;
public constructor () {
this.promise = new Promise<T>((resolve, reject) => {
this.resolveFn = resolve;
this.rejectFn = reject;
});
}
public reject (reason?: unknown): void {
this.rejectFn(reason);
}
public resolve (param: T): void {
this.resolveFn(param);
}
}

View File

@ -1,11 +1,12 @@
import { ProductCategory } from '../models/product-category';
import { stockMovementInReasons, stockMovementOutReasons, StockMovementReason } from '../models/product';
export default class ProductLib {
/**
* Map product categories by position
* @param categories unsorted categories, as returned by the API
*/
sortCategories = (categories: Array<ProductCategory>): Array<ProductCategory> => {
static sortCategories = (categories: Array<ProductCategory>): Array<ProductCategory> => {
const sortedCategories = categories
.filter(c => !c.parent_id)
.sort((a, b) => a.position - b.position);
@ -18,4 +19,33 @@ export default class ProductLib {
});
return sortedCategories;
};
/**
* Return the translation key associated with the given reason
*/
static stockMovementReasonTrKey = (reason: StockMovementReason): string => {
return `app.admin.store.stock_movement_reason.${reason}`;
};
/**
* Check if the given stock movement is of type 'in' or 'out'
*/
static stockMovementType = (reason: StockMovementReason): 'in' | 'out' => {
if ((stockMovementInReasons as readonly StockMovementReason[]).includes(reason)) return 'in';
if ((stockMovementOutReasons as readonly StockMovementReason[]).includes(reason)) return 'out';
throw new Error(`Unexpected stock movement reason: ${reason}`);
};
/**
* Return the given quantity, prefixed by its addition operator (- or +), if needed
*/
static absoluteStockMovement = (quantity: number, reason: StockMovementReason): string => {
if (ProductLib.stockMovementType(reason) === 'in') {
return `+${quantity}`;
} else {
if (quantity < 0) return quantity.toString();
return `-${quantity}`;
}
};
}

View File

@ -6,10 +6,13 @@ export interface ProductIndexFilter extends ApiFilter {
page?: number
}
export enum StockType {
internal = 'internal',
external = 'external'
}
export type StockType = 'internal' | 'external' | 'all';
export const stockMovementInReasons = ['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'other_in'] as const;
export const stockMovementOutReasons = ['sold', 'missing', 'damaged', 'other_out'] as const;
export const stockMovementAllReasons = [...stockMovementInReasons, ...stockMovementOutReasons] as const;
export type StockMovementReason = typeof stockMovementAllReasons[number];
export interface Stock {
internal: number,
@ -21,6 +24,16 @@ export interface ProductsIndex {
products: Array<Product>
}
export interface ProductStockMovement {
id?: number,
product_id?: number,
quantity?: number,
reason?: StockMovementReason,
stock_type?: StockType,
remaining_stock?: number,
date?: TDateISO
}
export interface Product {
id?: number,
name: string,
@ -35,6 +48,7 @@ export interface Product {
low_stock_alert: boolean,
low_stock_threshold?: number,
machine_ids: number[],
created_at?: TDateISO,
product_files_attributes: Array<{
id?: number,
attachment?: File,
@ -52,13 +66,5 @@ export interface Product {
_destroy?: boolean,
is_main?: boolean
}>,
product_stock_movements_attributes: Array<{
id?: number,
quantity?: number,
reason?: string,
stock_type?: string,
remaining_stock?: number,
date?: TDateISO,
_destroy?: boolean
}>,
product_stock_movements_attributes?: Array<ProductStockMovement>,
}

View File

@ -7,20 +7,20 @@ export const homePageSettings = [
'home_content',
'home_css',
'upcoming_events_shown'
];
] as const;
export const privacyPolicySettings = [
'privacy_draft',
'privacy_body',
'privacy_dpo'
];
] as const;
export const aboutPageSettings = [
'about_title',
'about_body',
'about_contacts',
'link_name'
];
] as const;
export const socialNetworksSettings = [
'facebook',
@ -36,7 +36,7 @@ export const socialNetworksSettings = [
'pinterest',
'lastfm',
'flickr'
];
] as const;
export const messagesSettings = [
'machine_explications_alert',
@ -45,7 +45,7 @@ export const messagesSettings = [
'subscription_explications_alert',
'event_explications_alert',
'space_explications_alert'
];
] as const;
export const invoicesSettings = [
'invoice_logo',
@ -65,7 +65,7 @@ export const invoicesSettings = [
'invoice_legals',
'invoice_prefix',
'payment_schedule_prefix'
];
] as const;
export const bookingSettings = [
'booking_window_start',
@ -82,17 +82,17 @@ export const bookingSettings = [
'book_overlapping_slots',
'slot_duration',
'overlapping_categories'
];
] as const;
export const themeSettings = [
'main_color',
'secondary_color'
];
] as const;
export const titleSettings = [
'fablab_name',
'name_genre'
];
] as const;
export const accountingSettings = [
'accounting_journal_code',
@ -118,7 +118,7 @@ export const accountingSettings = [
'accounting_Space_label',
'accounting_Product_code',
'accounting_Product_label'
];
] as const;
export const modulesSettings = [
'spaces_module',
@ -130,13 +130,13 @@ export const modulesSettings = [
'online_payment_module',
'public_agenda_module',
'invoicing_module'
];
] as const;
export const stripeSettings = [
'stripe_public_key',
'stripe_secret_key',
'stripe_currency'
];
] as const;
export const payzenSettings = [
'payzen_username',
@ -145,13 +145,13 @@ export const payzenSettings = [
'payzen_public_key',
'payzen_hmac',
'payzen_currency'
];
] as const;
export const openLabSettings = [
'openlab_app_id',
'openlab_app_secret',
'openlab_default'
];
] as const;
export const accountSettings = [
'phone_required',
@ -160,13 +160,13 @@ export const accountSettings = [
'user_change_group',
'user_validation_required',
'user_validation_required_list'
];
] as const;
export const analyticsSettings = [
'tracking_id',
'facebook_app_id',
'twitter_analytics'
];
] as const;
export const fabHubSettings = [
'hub_last_version',
@ -174,43 +174,43 @@ export const fabHubSettings = [
'fab_analytics',
'origin',
'uuid'
];
] as const;
export const projectsSettings = [
'allowed_cad_extensions',
'allowed_cad_mime_types',
'disqus_shortname'
];
] as const;
export const prepaidPacksSettings = [
'renew_pack_threshold',
'pack_only_for_subscription'
];
] as const;
export const registrationSettings = [
'public_registrations',
'recaptcha_site_key',
'recaptcha_secret_key'
];
] as const;
export const adminSettings = [
'feature_tour_display',
'show_username_in_admin_list'
];
] as const;
export const pricingSettings = [
'extended_prices_in_same_day'
];
] as const;
export const poymentSettings = [
'payment_gateway'
];
] as const;
export const displaySettings = [
'machines_sort_by',
'events_in_calendar',
'email_from'
];
] as const;
export const allSettings = [
...homePageSettings,

View File

@ -1,6 +1,31 @@
.product-stock-form {
h4 span { @include text-sm; }
.ongoing-stocks {
margin: 2.4rem 0;
.save-notice {
@include text-xs;
margin-left: 1rem;
color: var(--alert);
}
.unsaved-stock-movement {
background-color: var(--gray-soft-light);
border: 0;
padding: 1.2rem;
margin-top: 1rem;
.cancel-action {
&:hover {
text-decoration: underline;
cursor: pointer;
}
svg {
margin-left: 1rem;
vertical-align: middle;
}
}
}
}
.store-list {
h4 { margin: 0; }
}

View File

@ -15,5 +15,5 @@
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
</a>
</div>
<edit-product product-id="productId" on-success="onSuccess" on-error="onError"/>
</section>
<edit-product product-id="productId" on-success="onSuccess" on-error="onError" ui-router="uiRouter" />
</section>

View File

@ -15,5 +15,5 @@
<span translate>{{ 'app.admin.store.back_to_list' }}</span>
</a>
</div>
<new-product on-success="onSuccess" on-error="onError"/>
</section>
<new-product on-success="onSuccess" on-error="onError" ui-router="uiRouter"/>
</section>

View File

@ -8,7 +8,9 @@ class ProductStockMovement < ApplicationRecord
ALL_STOCK_TYPES = %w[internal external].freeze
enum stock_type: ALL_STOCK_TYPES.zip(ALL_STOCK_TYPES).to_h
ALL_REASONS = %w[incoming_stock returned_by_customer cancelled_by_customer sold missing_from_inventory damaged].freeze
INCOMING_REASONS = %w[inward_stock returned cancelled inventory_fix other_in].freeze
OUTGOING_REASONS = %w[sold missing damaged other_out].freeze
ALL_REASONS = [].concat(INCOMING_REASONS).concat(OUTGOING_REASONS).freeze
enum reason: ALL_REASONS.zip(ALL_REASONS).to_h
validates :stock_type, presence: true

View File

@ -13,4 +13,8 @@ class ProductPolicy < ApplicationPolicy
def destroy?
user.privileged?
end
def stock_movements?
user.privileged?
end
end

View File

@ -28,14 +28,14 @@ class Checkout::PaymentService
end
def confirm_payment(order, operator, coupon_code, payment_id = '')
if operator.member?
if Stripe::Helper.enabled?
Payments::StripeService.new.confirm_payment(order, coupon_code, payment_id)
elsif PayZen::Helper.enabled?
Payments::PayzenService.new.confirm_payment(order, coupon_code, payment_id)
else
raise Error('Bad gateway or online payment is disabled')
end
return unless operator.member?
if Stripe::Helper.enabled?
Payments::StripeService.new.confirm_payment(order, coupon_code, payment_id)
elsif PayZen::Helper.enabled?
Payments::PayzenService.new.confirm_payment(order, coupon_code, payment_id)
else
raise Error('Bad gateway or online payment is disabled')
end
end
end

View File

@ -33,24 +33,27 @@ module Payments::PaymentConcern
order.payment_gateway_object = PaymentGatewayObject.new(gateway_object_id: payment_id, gateway_object_type: payment_type)
end
order.order_items.each do |item|
ProductService.update_stock(item.orderable, 'external', 'sold', -item.quantity, item.id)
end
if order.save
invoice = InvoicesService.create(
{ total: order.total, coupon: coupon },
order.operator_profile_id,
order.order_items,
order.statistic_profile.user,
payment_id: payment_id,
payment_type: payment_type,
payment_method: order.payment_method
)
invoice.wallet_amount = order.wallet_amount
invoice.wallet_transaction_id = order.wallet_transaction_id
invoice.save
order.update_attribute(:invoice_id, invoice.id)
ProductService.update_stock(item.orderable,
[{ stock_type: 'external', reason: 'sold', quantity: item.quantity, order_item_id: item.id }]).save
end
create_invoice(order, coupon, payment_id, payment_type) if order.save
order.reload
end
end
def create_invoice(order, coupon, payment_id, payment_type)
invoice = InvoicesService.create(
{ total: order.total, coupon: coupon },
order.operator_profile_id,
order.order_items,
order.statistic_profile.user,
payment_id: payment_id,
payment_type: payment_type,
payment_method: order.payment_method
)
invoice.wallet_amount = order.wallet_amount
invoice.wallet_transaction_id = order.wallet_transaction_id
invoice.save
order.update(invoice_id: invoice.id)
end
end

View File

@ -2,45 +2,66 @@
# Provides methods for Product
class ProductService
PRODUCTS_PER_PAGE = 12
class << self
PRODUCTS_PER_PAGE = 12
def self.list(filters)
products = Product.includes(:product_images)
if filters[:is_active].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
products = products.where(is_active: state)
def list(filters)
products = Product.includes(:product_images)
if filters[:is_active].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
products = products.where(is_active: state)
end
products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present?
products
end
products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present?
products
end
def self.pages(filters)
products = Product.all
if filters[:is_active].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
products = Product.where(is_active: state)
def pages(filters)
products = Product.all
if filters[:is_active].present?
state = filters[:disabled] == 'false' ? [nil, false] : true
products = Product.where(is_active: state)
end
products.page(1).per(PRODUCTS_PER_PAGE).total_pages
end
products.page(1).per(PRODUCTS_PER_PAGE).total_pages
end
# amount params multiplied by hundred
def self.amount_multiplied_by_hundred(amount)
if amount.present?
v = amount.to_f
# amount params multiplied by hundred
def self.amount_multiplied_by_hundred(amount)
if amount.present?
v = amount.to_f
return nil if v.zero?
return nil if v.zero?
return v * 100
return v * 100
end
nil
end
nil
end
def self.update_stock(product, stock_type, reason, quantity, order_item_id = nil)
remaining_stock = product.stock[stock_type] + quantity
product.product_stock_movements.create(stock_type: stock_type, reason: reason, quantity: quantity, remaining_stock: remaining_stock,
date: DateTime.current,
order_item_id: order_item_id)
product.stock[stock_type] = remaining_stock
product.save
# @param product Product
# @param stock_movements [{stock_type: string, reason: string, quantity: number|string, order_item_id: number|nil}]
def update_stock(product, stock_movements = nil)
remaining_stock = { internal: product.stock['internal'], external: product.stock['external'] }
product.product_stock_movements_attributes = stock_movements&.map do |movement|
quantity = ProductStockMovement::OUTGOING_REASONS.include?(movement[:reason]) ? -movement[:quantity].to_i : movement[:quantity].to_i
remaining_stock[movement[:stock_type].to_sym] += quantity
{
stock_type: movement[:stock_type], reason: movement[:reason], quantity: quantity,
remaining_stock: remaining_stock[movement[:stock_type].to_sym], date: DateTime.current, order_item_id: movement[:order_item_id]
}
end || {}
product.stock = remaining_stock
product
end
def create(product_params, stock_movement_params = [])
product = Product.new(product_params)
update(product, product_params, stock_movement_params)
end
def update(product, product_params, stock_movement_params = [])
product_params[:amount] = amount_multiplied_by_hundred(product_params[:amount])
product.attributes = product_params
update_stock(product, stock_movement_params)
product
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :low_stock_alert,
:low_stock_threshold, :machine_ids
:low_stock_threshold, :machine_ids, :created_at
json.description sanitize(product.description)
json.amount product.amount / 100.0 if product.amount.present?
json.product_files_attributes product.product_files do |f|
@ -15,11 +15,3 @@ json.product_images_attributes product.product_images do |f|
json.attachment_url f.attachment_url
json.is_main f.is_main
end
json.product_stock_movements_attributes product.product_stock_movements do |s|
json.id s.id
json.quantity s.quantity
json.reason s.reason
json.stock_type s.stock_type
json.remaining_stock s.remaining_stock
json.date s.date
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.extract! stock_movement, :id, :quantity, :reason, :stock_type, :remaining_stock, :date

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
json.array! @movements do |movement|
json.partial! 'api/products/stock_movement', stock_movement: movement
json.extract! movement, :product_id
end

View File

@ -1995,44 +1995,48 @@ en:
add_product_image: "Add an image"
save: "Save"
product_stock_form:
stock_up_to_date: "Stock up to date"
date_time: "{DATE} - {TIME}"
ongoing_operations: "Ongoing stocks operations"
save_reminder: "Don't forget to save your operations"
low_stock_threshold: "Define a low stock threshold"
stock_threshold_toggle: "Activate stock threshold"
stock_threshold_information: "<strong>Information</strong></br>Define a low stock threshold and receive a notification when it's reached.<br>Above the threshold, the product is available in the store. When the threshold is reached, the product quantity is labeled as low."
low_stock: "Low stock"
threshold_level: "Minimum threshold level"
threshold_alert: "Notify me when the threshold is reached"
events_history: "Events history"
event_type: "Events:"
reason: "Reason"
stocks: "Stocks:"
internal: "Private stock"
external: "Public stock"
all: "All types"
stock_level: "Stock level"
events:
inward_stock: "Inward stock"
returned: "Returned by client"
canceled: "Canceled by client"
sold: "Sold"
missing: "Missing in stock"
damaged: "Damaged product"
events_history: "Events history"
modal_title: "Manage stock"
remaining_stock: "Remaining stock"
type_in: "Add"
type_out: "Remove"
cancel: "Cancel this operation"
product_stock_modal:
modal_title: "Manage stock"
internal: "Private stock"
external: "Public stock"
new_event: "New stock event"
addition: "Addition"
withdrawal: "Withdrawal"
update_stock: "Update stock"
event_type: "Events:"
reason_type: "Reason"
stocks: "Stocks:"
quantity: "Quantity"
events:
inward_stock: "Inward stock"
returned: "Returned by client"
canceled: "Canceled by client"
sold: "Sold"
missing: "Missing in stock"
damaged: "Damaged product"
stock_movement_reason:
inward_stock: "Inward stock"
returned: "Returned by client"
cancelled: "Canceled by client"
inventory_fix: "Inventory fix"
sold: "Sold"
missing: "Missing in stock"
damaged: "Damaged product"
other_in: "Other"
other_out: "Other"
orders:
heading: "Orders"
create_order: "Create an order"

View File

@ -590,3 +590,7 @@ en:
ready: "Ready"
collected: "Collected"
refunded: "Refunded"
unsaved_form_alert:
modal_title: "You have some unsaved changes"
confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?"
confirmation_button: "Yes, don't save"

View File

@ -154,7 +154,9 @@ Rails.application.routes.draw do
patch 'position', on: :member
end
resources :products
resources :products do
get 'stock_movements', on: :member
end
resources :cart, only: %i[create] do
put 'add_item', on: :collection
put 'remove_item', on: :collection

View File

@ -3299,20 +3299,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001313:
version "1.0.30001314"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001314.tgz#65c7f9fb7e4594fca0a333bec1d8939662377596"
integrity sha512-0zaSO+TnCHtHJIbpLroX7nsD+vYuOVjl3uzFbJO1wMVbuveJA0RK2WcQA9ZUIOiO0/ArMiMgHJLxfEZhQiC0kw==
caniuse-lite@^1.0.30001219:
version "1.0.30001296"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz"
integrity sha512-WfrtPEoNSoeATDlf4y3QvkwiELl9GyPLISV5GejTbbQRtQx4LhsXmc9IQ6XCL2d7UxCyEzToEZNMeqR79OUw8Q==
caniuse-lite@^1.0.30001332:
version "1.0.30001335"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001335.tgz#899254a0b70579e5a957c32dced79f0727c61f2a"
integrity sha512-ddP1Tgm7z2iIxu6QTtbZUv6HJxSaV/PZeSrWFZtbY4JZ69tOeNhBCl3HyRQgeNZKE5AOn1kpV7fhljigy0Ty3w==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332:
version "1.0.30001397"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001397.tgz"
integrity sha512-SW9N2TbCdLf0eiNDRrrQXx2sOkaakNZbCjgNpPyMJJbiOrU5QzMIrXOVMRM1myBXTD5iTkdrtU/EguCrBocHlA==
chalk@^2.0.0, chalk@^2.4.2:
version "2.4.2"