Skip to content

Commit

Permalink
feat(server): product automatic stock management (#195)
Browse files Browse the repository at this point in the history
  • Loading branch information
eilrix committed May 10, 2022
1 parent 8a7e179 commit cdda118
Show file tree
Hide file tree
Showing 13 changed files with 110 additions and 16 deletions.
34 changes: 30 additions & 4 deletions system/admin-panel/src/pages/product/MainInfoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { resolvePageRoute, serviceLocator, TProductVariant, TProduct, TStockStatus, getRandStr } from '@cromwell/core';
import { Autocomplete, Grid, TextField, Tooltip } from '@mui/material';
import { getRandStr, resolvePageRoute, serviceLocator, TProduct, TProductVariant, TStockStatus } from '@cromwell/core';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { Autocomplete, Checkbox, FormControlLabel, FormGroup, Grid, TextField, Tooltip } from '@mui/material';
import React, { useEffect } from 'react';
import { debounce } from 'throttle-debounce';

import { GalleryPicker } from '../../components/galleryPicker/GalleryPicker';
import { Select } from '../../components/select/Select';
import { getEditorData, getEditorHtml, initTextEditor } from '../../helpers/editor/editor';
import { useForceUpdate } from '../../helpers/forceUpdate';
import { NumberFormatCustom } from '../../helpers/NumberFormatCustom';
import styles from './Product.module.scss';
import { debounce } from 'throttle-debounce';


const MainInfoCard = (props: {
product: TProduct | TProductVariant,
Expand Down Expand Up @@ -104,6 +104,7 @@ const MainInfoCard = (props: {
onChange={(e) => { handleChange('sku', e.target.value) }}
/>
</Grid>
<Grid item xs={12} sm={6}></Grid>
<Grid item xs={12} sm={6}>
<Select
fullWidth
Expand All @@ -114,6 +115,31 @@ const MainInfoCard = (props: {
options={['In stock', 'Out of stock', 'On backorder'] as TStockStatus[]}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField label="Stock amount" variant="standard"
value={product.stockAmount ?? ''}
className={styles.textField}
type="number"
onChange={(e) => {
let val = parseInt(e.target.value);
if (isNaN(val)) val = null;
if (val && val < 0) val = 0;
handleChange('stockAmount', val);
}}
/>
</Grid>
<Grid item xs={12} sm={12} style={{ display: 'flex', alignItems: 'center' }}>
<FormGroup>
<FormControlLabel control={<Checkbox defaultChecked />}
label="Manage stock"
checked={!!product?.manageStock}
onChange={() => handleChange('manageStock', !product?.manageStock)}
/>
</FormGroup>
<Tooltip title="Automatically manage stock amount when new orders placed">
<InfoOutlinedIcon />
</Tooltip>
</Grid>
<Grid item xs={12} sm={6}>
<TextField label="Price" variant="standard"
value={product.price ?? ''}
Expand Down
2 changes: 2 additions & 0 deletions system/admin-panel/src/pages/product/Product.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ const ProductPage = () => {
images: product.images,
stockStatus: product.stockStatus ?? 'In stock',
stockAmount: product.stockAmount,
manageStock: product.manageStock,
description: product.description,
descriptionDelta: product.descriptionDelta,
slug: product.slug,
Expand All @@ -200,6 +201,7 @@ const ProductPage = () => {
descriptionDelta: variant.descriptionDelta,
stockAmount: variant.stockAmount,
stockStatus: variant.stockStatus,
manageStock: variant.manageStock,
attributes: variant.attributes,
})),
customMeta: Object.assign({}, product.customMeta, await getCustomMetaFor(EDBEntity.Product)),
Expand Down
2 changes: 2 additions & 0 deletions system/core/backend/src/helpers/emailing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export const sendEmail = async (addresses: string[], subject: string, htmlConten
})
}
return new Promise(done => {
setTimeout(() => done(false), 120000);

sendmailTransporter(messageContent, (err, reply) => {
logger.log(reply);
if (err)
Expand Down
4 changes: 3 additions & 1 deletion system/core/backend/src/helpers/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export const getLogger = (writeToFile = true) => {
loggerFormat
),
transports: [
new winston.transports.Console(),
new winston.transports.Console({
stderrLevels: ['error']
}),
],
});
}
Expand Down
2 changes: 1 addition & 1 deletion system/core/frontend/src/helpers/CStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export class CStore {
const pickedValues = productPickedAttributes[attrKey] || [];
if (values.length !== pickedValues.length) return false;
return values.every(key => pickedValues.includes(key))
})
});
}
return false;
};
Expand Down
56 changes: 55 additions & 1 deletion system/server/src/services/store.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
standardShipping,
TOrder,
TOrderInput,
TProduct,
TProductVariant,
TStoreListItem,
} from '@cromwell/core';
import {
Expand Down Expand Up @@ -162,6 +164,58 @@ export class StoreService {
});
}

const attributes = await getCustomRepository(AttributeRepository).getAll();

// Update stock of products / product variants
await Promise.all(orderTotal.cart?.map(async item => {
const product = item.product?.id && await getCustomRepository(ProductRepository)
.getProductById(item.product.id, { withVariants: true }) || null;

if (!product) return;

const decreaseStock = (product: TProduct | TProductVariant) => {
if (product.manageStock && product.stockAmount) {
product.stockAmount = product.stockAmount - (item.amount ?? 1);
if (product.stockAmount < 0) {
throw new HttpException(`Product ${product.name ?? item.product?.name} is not available in amount ${item.amount ?? 1}`,
HttpStatus.BAD_REQUEST);
}

if (product.stockAmount === 0) {
product.stockStatus = 'Out of stock';
}
}
}

if (!item.pickedAttributes) {
// Manage stock of main product record
decreaseStock(product);
} else {
// Manage product variants
// Find picked variant (if it is created)
const variant = product?.variants?.find(variant => {
return Object.entries(item.pickedAttributes ?? {}).every(([key, values]) => {
const attribute = attributes.find(attr => attr.key === key);
if (attribute?.type === 'radio')
return variant.attributes?.[key] === values[0];

// Attribute type `checkbox` is not supported for auto management
return false;
});
});

if (variant) {
decreaseStock(variant);
} else {
// Variant not found, use main product info
decreaseStock(product);
}
}
await product?.save();
}));


// Apply coupons
if (orderTotal.appliedCoupons?.length) {
try {
const coupons = await getCustomRepository(CouponRepository)
Expand Down Expand Up @@ -251,7 +305,7 @@ export class StoreService {
}
// < / Send e-mail >

return getCustomRepository(OrderRepository).createOrder(createOrder);
return await getCustomRepository(OrderRepository).createOrder(createOrder);
}

}
2 changes: 1 addition & 1 deletion system/server/startup.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const main = () => {
{ shell: true, stdio: 'pipe', cwd: serverRootDir });

rollupProc.stdout.on('data', buff => console.log((buff && buff.toString) ? buff.toString() : buff));
rollupProc.stderr.on('data', buff => console.log((buff && buff.toString) ? buff.toString() : buff));
rollupProc.stderr.on('data', buff => console.error((buff && buff.toString) ? buff.toString() : buff));

setTimeout(() => {
process.send(serverMessages.onStartMessage);
Expand Down
2 changes: 1 addition & 1 deletion themes/store/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cromwell/theme-store",
"version": "2.0.8",
"version": "2.0.9",
"license": "MIT",
"repository": "https://github.com/CromwellCMS/Cromwell",
"author": "Astrex LLC",
Expand Down
1 change: 1 addition & 0 deletions toolkits/commerce/src/base/CartList/CartListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function CartListItem(props: CartListItemProps) {
</div>
</div>
<div className={clsx(styles.attributesBlock, classes?.attributesBlock)}>
<p key="__amount" className={clsx(styles.attributeValue, classes?.attributeValue)}>Amount: {item.amount ?? 1}</p>
{checkedAttrKeys.map(key => {
const values = item.pickedAttributes?.[key];
if (!values?.length || !key) return null;
Expand Down
5 changes: 5 additions & 0 deletions toolkits/commerce/src/base/Checkout/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export type CheckoutProps = {
onGetOrderTotal?: (data: TOrderPaymentSession | undefined | null) => void;
onPlaceOrder?: (placedOrder: TOrder | undefined | null) => void;
onPay?: (success: boolean) => void;

/**
* Change text of backend errors before showing notifications.
*/
changeErrorText?: (message: string) => string;
}

/**
Expand Down
5 changes: 3 additions & 2 deletions toolkits/commerce/src/base/Checkout/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const usuCheckoutActions = (config: {
const cstore = getCStore();
const { notifier = baseNotifier, notifierOptions = {},
fields = getDefaultCheckoutFields(config.checkoutProps),
text, getPaymentOptions } = config.checkoutProps;
text, getPaymentOptions, changeErrorText } = config.checkoutProps;

const [isLoading, setIsLoading] = useState(false);
const [isAwaitingPayment, setIsAwaitingPayment] = useState(false);
Expand Down Expand Up @@ -189,7 +189,8 @@ export const usuCheckoutActions = (config: {
couponCodes: Object.values(coupons.current).map(c => c.value).filter(Boolean),
})).catch(e => {
console.error(e);
notifier?.error?.(text?.failedCreateOrder ?? 'Failed to create order',
notifier?.error?.((text?.failedCreateOrder ?? 'Failed to place order.') + ' ' +
(changeErrorText ?? ((m) => m))(e.message),
{ ...notifierOptions, });
}) || null;

Expand Down
9 changes: 5 additions & 4 deletions toolkits/commerce/src/base/ProductActions/ProductActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ export function ProductActions(props: ProductActionsProps) {
// }
// }

const outOfStock = (modifiedProduct?.stockStatus === 'Out of stock' ||
modifiedProduct?.stockStatus === 'On backorder');
const outOfStock = !!((modifiedProduct?.stockStatus === 'Out of stock' ||
modifiedProduct?.stockStatus === 'On backorder') ||
(modifiedProduct?.manageStock && modifiedProduct?.stockAmount && modifiedProduct?.stockAmount < amount));

let cartButtonText = text?.addToCart ?? 'Add to cart';
if (inCart) {
Expand All @@ -229,8 +230,8 @@ export function ProductActions(props: ProductActionsProps) {
}
}

if (outOfStock && modifiedProduct?.stockStatus) {
cartButtonText = modifiedProduct?.stockStatus;
if (outOfStock) {
cartButtonText = text?.outOfStock ?? 'Out of stock';
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export type ProductReviewsProps = {
/**
* Override props to CList
*/
listProps?: TCListProps<TProductReview, TReviewListItemProps>;
listProps?: Partial<TCListProps<TProductReview, TReviewListItemProps>>;
/**
* Notifier tool
*/
Expand Down

0 comments on commit cdda118

Please sign in to comment.