diff --git a/src/Domain.LinnApps/AuthorisedAction.cs b/src/Domain.LinnApps/AuthorisedAction.cs index 12e2ab880..823c547e5 100644 --- a/src/Domain.LinnApps/AuthorisedAction.cs +++ b/src/Domain.LinnApps/AuthorisedAction.cs @@ -15,5 +15,7 @@ public class AuthorisedAction public const string SupplierHoldChange = "purchasing.supplier.hold-change"; public const string PurchaseOrderUpdate = "purchase-order.update"; + + public const string PlCreditDebitNoteClose = "purchasing.pl-debit-credit-note.close"; } } diff --git a/src/Domain.LinnApps/PurchaseOrders/IPlCreditDebitNoteService.cs b/src/Domain.LinnApps/PurchaseOrders/IPlCreditDebitNoteService.cs new file mode 100644 index 000000000..d0c6b523a --- /dev/null +++ b/src/Domain.LinnApps/PurchaseOrders/IPlCreditDebitNoteService.cs @@ -0,0 +1,13 @@ +namespace Linn.Purchasing.Domain.LinnApps.PurchaseOrders +{ + using System.Collections.Generic; + + public interface IPlCreditDebitNoteService + { + public PlCreditDebitNote CloseDebitNote( + PlCreditDebitNote toClose, + string reason, + int closedBy, + IEnumerable privileges); + } +} diff --git a/src/Domain.LinnApps/PurchaseOrders/PlCreditDebitNote.cs b/src/Domain.LinnApps/PurchaseOrders/PlCreditDebitNote.cs new file mode 100644 index 000000000..cf3749769 --- /dev/null +++ b/src/Domain.LinnApps/PurchaseOrders/PlCreditDebitNote.cs @@ -0,0 +1,37 @@ +namespace Linn.Purchasing.Domain.LinnApps.PurchaseOrders +{ + using System; + + using Linn.Purchasing.Domain.LinnApps.Suppliers; + + public class PlCreditDebitNote + { + public int NoteNumber { get; set; } + + public string NoteType { get; set; } + + public string PartNumber { get; set; } + + public int OrderQty { get; set; } + + public int? OriginalOrderNumber { get; set; } + + public int? ReturnsOrderNumber { get; set; } + + public decimal NetTotal { get; set; } + + public string Notes { get; set; } + + public DateTime? DateClosed { get; set; } + + public DateTime DateCreated { get; set; } + + public int? ClosedBy { get; set; } + + public string ReasonClosed { get; set; } + + public int? SupplierId { get; set; } + + public Supplier Supplier { get; set; } + } +} diff --git a/src/Domain.LinnApps/PurchaseOrders/PlCreditDebitNoteService.cs b/src/Domain.LinnApps/PurchaseOrders/PlCreditDebitNoteService.cs new file mode 100644 index 000000000..048c53abf --- /dev/null +++ b/src/Domain.LinnApps/PurchaseOrders/PlCreditDebitNoteService.cs @@ -0,0 +1,35 @@ +namespace Linn.Purchasing.Domain.LinnApps.PurchaseOrders +{ + using System; + using System.Collections.Generic; + + using Linn.Common.Authorisation; + using Linn.Purchasing.Domain.LinnApps.Exceptions; + + public class PlCreditDebitNoteService : IPlCreditDebitNoteService + { + private readonly IAuthorisationService authService; + + public PlCreditDebitNoteService(IAuthorisationService authService) + { + this.authService = authService; + } + + public PlCreditDebitNote CloseDebitNote( + PlCreditDebitNote toClose, + string reason, + int closedBy, + IEnumerable privileges) + { + if (!this.authService.HasPermissionFor(AuthorisedAction.PlCreditDebitNoteClose, privileges)) + { + throw new UnauthorisedActionException("You are not authorised to close debit notes"); + } + + toClose.DateClosed = DateTime.Today; + toClose.ReasonClosed = reason; + toClose.ClosedBy = closedBy; + return toClose; + } + } +} diff --git a/src/Facade/ResourceBuilders/PlCreditDebitNoteResourceBuilder.cs b/src/Facade/ResourceBuilders/PlCreditDebitNoteResourceBuilder.cs new file mode 100644 index 000000000..fedef116d --- /dev/null +++ b/src/Facade/ResourceBuilders/PlCreditDebitNoteResourceBuilder.cs @@ -0,0 +1,38 @@ +namespace Linn.Purchasing.Facade.ResourceBuilders +{ + using System; + using System.Collections.Generic; + + using Linn.Common.Facade; + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + using Linn.Purchasing.Resources; + + public class PlCreditDebitNoteResourceBuilder : IBuilder + { + public PlCreditDebitNoteResource Build(PlCreditDebitNote note, IEnumerable claims) + { + return new PlCreditDebitNoteResource + { + OrderQty = note.OrderQty, + PartNumber = note.PartNumber, + DateClosed = note.DateClosed?.ToString("o"), + SupplierId = note.SupplierId, + ClosedBy = note.ClosedBy, + NetTotal = note.NetTotal, + NoteNumber = note.NoteNumber, + OriginalOrderNumber = note.OriginalOrderNumber, + ReturnsOrderNumber = note.ReturnsOrderNumber, + Notes = note.Notes, + SupplierName = note.Supplier?.Name, + DateCreated = note.DateCreated.ToShortDateString() + }; + } + + public string GetLocation(PlCreditDebitNote p) + { + throw new NotImplementedException(); + } + + object IBuilder.Build(PlCreditDebitNote entity, IEnumerable claims) => this.Build(entity, claims); + } +} diff --git a/src/Facade/Services/PlCreditDebitNoteFacadeService.cs b/src/Facade/Services/PlCreditDebitNoteFacadeService.cs new file mode 100644 index 000000000..330119661 --- /dev/null +++ b/src/Facade/Services/PlCreditDebitNoteFacadeService.cs @@ -0,0 +1,82 @@ +namespace Linn.Purchasing.Facade.Services +{ + using System; + using System.Collections.Generic; + using System.Linq.Expressions; + + using Linn.Common.Facade; + using Linn.Common.Persistence; + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + using Linn.Purchasing.Resources; + + public class PlCreditDebitNoteFacadeService + : FacadeFilterResourceService + { + private readonly IPlCreditDebitNoteService domainService; + + public PlCreditDebitNoteFacadeService( + IRepository repository, + ITransactionManager transactionManager, + IBuilder builder, + IPlCreditDebitNoteService domainService) + : base(repository, transactionManager, builder) + { + this.domainService = domainService; + } + + protected override PlCreditDebitNote CreateFromResource( + PlCreditDebitNoteResource resource, + IEnumerable privileges = null) + { + throw new NotImplementedException(); + } + + protected override void UpdateFromResource( + PlCreditDebitNote entity, + PlCreditDebitNoteResource updateResource, + IEnumerable privileges = null) + { + if (updateResource.ClosedBy.HasValue && updateResource.Close.HasValue && (bool)updateResource.Close) + { + this.domainService.CloseDebitNote( + entity, + updateResource.ReasonClosed, + (int)updateResource.ClosedBy, + privileges); + } + + entity.Notes = updateResource.Notes; + } + + protected override Expression> SearchExpression( + string searchTerm) + { + throw new NotImplementedException(); + } + + protected override void SaveToLogTable( + string actionType, + int userNumber, + PlCreditDebitNote entity, + PlCreditDebitNoteResource resource, + PlCreditDebitNoteResource updateResource) + { + throw new NotImplementedException(); + } + + protected override void DeleteOrObsoleteResource( + PlCreditDebitNote entity, IEnumerable privileges = null) + { + throw new NotImplementedException(); + } + + protected override Expression> FilterExpression( + PlCreditDebitNoteResource searchResource) + { + var date = string.IsNullOrEmpty(searchResource.DateClosed) + ? (DateTime?)null + : DateTime.Parse(searchResource.DateClosed); + return x => x.DateClosed == date && x.NoteType == searchResource.NoteType; + } + } +} diff --git a/src/IoC/HandlerExtensions.cs b/src/IoC/HandlerExtensions.cs index 4ebf694a2..b58acc5f5 100644 --- a/src/IoC/HandlerExtensions.cs +++ b/src/IoC/HandlerExtensions.cs @@ -54,7 +54,9 @@ public static IServiceCollection AddHandlers(this IServiceCollection services) .AddTransient>>() .AddTransient>() .AddTransient>>() - .AddTransient>>(); + .AddTransient>>() + .AddTransient>>() + .AddTransient>(); } } } diff --git a/src/IoC/PersistenceExtensions.cs b/src/IoC/PersistenceExtensions.cs index c97a57d81..48c2d1a31 100644 --- a/src/IoC/PersistenceExtensions.cs +++ b/src/IoC/PersistenceExtensions.cs @@ -72,7 +72,8 @@ public static IServiceCollection AddPersistence(this IServiceCollection services r => new EntityFrameworkRepository(r.GetService()?.SupplierGroups)) .AddTransient, SupplierContactRepository>() .AddTransient, EntityFrameworkRepository>( - r => new EntityFrameworkRepository(r.GetService()?.Persons)); + r => new EntityFrameworkRepository(r.GetService()?.Persons)) + .AddTransient, PlCreditDebitNoteRepository>(); } } } diff --git a/src/IoC/ServiceExtensions.cs b/src/IoC/ServiceExtensions.cs index 37df1aa5f..5a413822b 100644 --- a/src/IoC/ServiceExtensions.cs +++ b/src/IoC/ServiceExtensions.cs @@ -53,7 +53,8 @@ public static IServiceCollection AddBuilders(this IServiceCollection services) .AddTransient, VendorManagerResourceBuilder>() .AddTransient, PlannerResourceBuilder>() .AddTransient, SupplierGroupResourceBuilder>() - .AddTransient, SupplierContactResourceBuilder>(); + .AddTransient, SupplierContactResourceBuilder>() + .AddTransient, PlCreditDebitNoteResourceBuilder>(); } public static IServiceCollection AddFacades(this IServiceCollection services) @@ -80,7 +81,8 @@ public static IServiceCollection AddFacades(this IServiceCollection services) .AddTransient, CountryService>() .AddTransient, VendorManagerFacadeService>().AddTransient() .AddTransient, PlannerService>() - .AddTransient, SupplierGroupFacadeService>(); + .AddTransient, SupplierGroupFacadeService>() + .AddTransient, PlCreditDebitNoteFacadeService>(); } public static IServiceCollection AddServices(this IServiceCollection services) @@ -99,6 +101,7 @@ public static IServiceCollection AddServices(this IServiceCollection services) .AddTransient() .AddTransient() .AddTransient() + .AddTransient() // external services .AddTransient() diff --git a/src/Persistence.LinnApps/Repositories/PlCreditDebitNoteRepository.cs b/src/Persistence.LinnApps/Repositories/PlCreditDebitNoteRepository.cs new file mode 100644 index 000000000..324299dab --- /dev/null +++ b/src/Persistence.LinnApps/Repositories/PlCreditDebitNoteRepository.cs @@ -0,0 +1,24 @@ +namespace Linn.Purchasing.Persistence.LinnApps.Repositories +{ + using System; + using System.Linq; + using System.Linq.Expressions; + + using Linn.Common.Persistence.EntityFramework; + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + + using Microsoft.EntityFrameworkCore; + + public class PlCreditDebitNoteRepository : EntityFrameworkRepository + { + public PlCreditDebitNoteRepository(ServiceDbContext serviceDbContext) + : base(serviceDbContext.PlCreditDebitNotes) + { + } + + public override IQueryable FilterBy(Expression> expression) + { + return base.FilterBy(expression).Include(n => n.Supplier); + } + } +} diff --git a/src/Persistence.LinnApps/ServiceDbContext.cs b/src/Persistence.LinnApps/ServiceDbContext.cs index 5eaad73c4..13a6bda4a 100644 --- a/src/Persistence.LinnApps/ServiceDbContext.cs +++ b/src/Persistence.LinnApps/ServiceDbContext.cs @@ -81,6 +81,8 @@ public class ServiceDbContext : DbContext public DbSet Persons { get; set; } + public DbSet PlCreditDebitNotes { get; set; } + protected override void OnModelCreating(ModelBuilder builder) { builder.Model.AddAnnotation("MaxIdentifierLength", 30); @@ -121,6 +123,7 @@ protected override void OnModelCreating(ModelBuilder builder) this.BuildSupplierGroups(builder); this.BuildSupplierContacts(builder); this.BuildPersons(builder); + this.BuildPlCreditDebitNotes(builder); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -654,5 +657,25 @@ private void BuildSupplierGroups(ModelBuilder builder) entity.Property(e => e.Id).HasColumnName("ID"); entity.Property(e => e.Name).HasColumnName("NAME"); } + + private void BuildPlCreditDebitNotes(ModelBuilder builder) + { + var entity = builder.Entity().ToTable("PL_CREDIT_DEBIT_NOTES"); + entity.HasKey(a => a.NoteNumber); + entity.Property(a => a.NoteNumber).HasColumnName("CDNOTE_ID"); + entity.Property(a => a.PartNumber).HasColumnName("PART_NUMBER").HasMaxLength(14); + entity.Property(a => a.OrderQty).HasColumnName("ORDER_QTY"); + entity.Property(a => a.ClosedBy).HasColumnName("CLOSED_BY"); + entity.Property(a => a.DateClosed).HasColumnName("DATE_CLOSED"); + entity.Property(a => a.NetTotal).HasColumnName("NET_TOTAL"); + entity.Property(a => a.NoteType).HasColumnName("CDNOTE_TYPE").HasMaxLength(1); + entity.Property(a => a.OriginalOrderNumber).HasColumnName("ORIGINAL_ORDER_NUMBER"); + entity.Property(a => a.ReturnsOrderNumber).HasColumnName("RETURNS_ORDER_NUMBER"); + entity.Property(a => a.Notes).HasColumnName("NOTES").HasMaxLength(200); + entity.Property(a => a.ReasonClosed).HasColumnName("REASON_CLOSED").HasMaxLength(2000); + entity.Property(a => a.SupplierId).HasColumnName("SUPPLIER_ID"); + entity.HasOne(a => a.Supplier).WithMany().HasForeignKey(a => a.SupplierId); + entity.Property(a => a.DateCreated).HasColumnName("DATE_CREATED"); + } } } diff --git a/src/Resources/PlCreditDebitNoteResource.cs b/src/Resources/PlCreditDebitNoteResource.cs new file mode 100644 index 000000000..7b2806837 --- /dev/null +++ b/src/Resources/PlCreditDebitNoteResource.cs @@ -0,0 +1,39 @@ +namespace Linn.Purchasing.Resources +{ + using System.Collections.Generic; + + public class PlCreditDebitNoteResource + { + public int NoteNumber { get; set; } + + public string NoteType { get; set; } + + public string PartNumber { get; set; } + + public int OrderQty { get; set; } + + public int? OriginalOrderNumber { get; set; } + + public int? ReturnsOrderNumber { get; set; } + + public decimal NetTotal { get; set; } + + public string Notes { get; set; } + + public string DateClosed { get; set; } + + public int? ClosedBy { get; set; } + + public string ReasonClosed { get; set; } + + public int? SupplierId { get; set; } + + public string SupplierName { get; set; } + + public IEnumerable UserPrivileges { get; set; } + + public string DateCreated { get; set; } + + public bool? Close { get; set; } + } +} diff --git a/src/Service.Host/client/src/actions/index.js b/src/Service.Host/client/src/actions/index.js index 2bcb05387..83df412be 100644 --- a/src/Service.Host/client/src/actions/index.js +++ b/src/Service.Host/client/src/actions/index.js @@ -105,3 +105,7 @@ export const spendByPartReportActionTypes = makeActionTypes( reportTypes.spendByPartReport.actionType, false ); + +export const plCreditDebitNoteActionTypes = makeActionTypes(itemTypes.plCreditDebitNote.actionType); + +export const openDebitNotesActionTypes = makeActionTypes(itemTypes.openDebitNotes.actionType); diff --git a/src/Service.Host/client/src/actions/openDebitNotesActions.js b/src/Service.Host/client/src/actions/openDebitNotesActions.js new file mode 100644 index 000000000..dd05f068c --- /dev/null +++ b/src/Service.Host/client/src/actions/openDebitNotesActions.js @@ -0,0 +1,12 @@ +import { FetchApiActions } from '@linn-it/linn-form-components-library'; +import { openDebitNotesActionTypes as actionTypes } from './index'; +import * as itemTypes from '../itemTypes'; +import config from '../config'; + +export default new FetchApiActions( + itemTypes.openDebitNotes.item, + itemTypes.openDebitNotes.actionType, + itemTypes.openDebitNotes.uri, + actionTypes, + config.appRoot +); diff --git a/src/Service.Host/client/src/actions/plCreditDebitNoteActions.js b/src/Service.Host/client/src/actions/plCreditDebitNoteActions.js new file mode 100644 index 000000000..703d3bc7d --- /dev/null +++ b/src/Service.Host/client/src/actions/plCreditDebitNoteActions.js @@ -0,0 +1,12 @@ +import { UpdateApiActions } from '@linn-it/linn-form-components-library'; +import { plCreditDebitNoteActionTypes as actionTypes } from './index'; +import * as itemTypes from '../itemTypes'; +import config from '../config'; + +export default new UpdateApiActions( + itemTypes.plCreditDebitNote.item, + itemTypes.plCreditDebitNote.actionType, + itemTypes.plCreditDebitNote.uri, + actionTypes, + config.appRoot +); diff --git a/src/Service.Host/client/src/components/Root.js b/src/Service.Host/client/src/components/Root.js index e729d6209..96022acd4 100644 --- a/src/Service.Host/client/src/components/Root.js +++ b/src/Service.Host/client/src/components/Root.js @@ -27,6 +27,7 @@ import SuppliersWithUnacknowledgedOrders from './reports/SuppliersWithUnacknowle import UnacknowledgedOrdersReport from './reports/UnacknowledgedOrdersReport'; import SpendByPartOptions from './reports/SpendByPartOptions'; import SpendByPart from './reports/SpendByPart'; +import OpenDebitNotes from './plDebitCreditNotes/OpenDebitNotes'; const Root = ({ store }) => (
@@ -137,7 +138,11 @@ const Root = ({ store }) => ( path="/purchasing/reports/spend-by-part/report" component={SpendByPart} /> - +
diff --git a/src/Service.Host/client/src/components/plDebitCreditNotes/OpenDebitNotes.js b/src/Service.Host/client/src/components/plDebitCreditNotes/OpenDebitNotes.js new file mode 100644 index 000000000..1cc44446f --- /dev/null +++ b/src/Service.Host/client/src/components/plDebitCreditNotes/OpenDebitNotes.js @@ -0,0 +1,341 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + Title, + Loading, + SnackbarMessage, + ErrorCard, + InputField, + Page, + collectionSelectorHelpers, + itemSelectorHelpers, + getItemError +} from '@linn-it/linn-form-components-library'; +import Grid from '@mui/material/Grid'; +import Dialog from '@mui/material/Dialog'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import CloseIcon from '@mui/icons-material/Close'; +import { DataGrid } from '@mui/x-data-grid'; +import { makeStyles } from '@mui/styles'; +import plCreditDebitNoteActions from '../../actions/plCreditDebitNoteActions'; +import openDebitNotesActions from '../../actions/openDebitNotesActions'; + +function OpenDebitNotes() { + const dispatch = useDispatch(); + const [rows, setRows] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [commentsDialogOpen, setCommentsDialogOpen] = useState(false); + + const [closeReason, setCloseReason] = useState(''); + const [comments, setComments] = useState(''); + + const items = useSelector(state => collectionSelectorHelpers.getItems(state.openDebitNotes)); + const itemsLoading = useSelector(state => + collectionSelectorHelpers.getLoading(state.openDebitNotes) + ); + + const snackbarVisible = useSelector(state => + itemSelectorHelpers.getSnackbarVisible(state.plCreditDebitNote) + ); + const updateError = useSelector(state => getItemError(state, 'plCreditDebitNote')); + const updateLoading = useSelector(state => + itemSelectorHelpers.getItemLoading(state.plCreditDebitNote) + ); + const updatedItem = useSelector(state => itemSelectorHelpers.getItem(state.plCreditDebitNote)); + + useEffect(() => { + if (updatedItem) { + dispatch(openDebitNotesActions.fetch()); + } + }, [updatedItem, updateLoading, dispatch]); + + const useStyles = makeStyles(theme => ({ + dialog: { + margin: theme.spacing(6), + minWidth: theme.spacing(62) + }, + total: { + float: 'right' + } + })); + + useEffect(() => dispatch(openDebitNotesActions.fetch()), [dispatch]); + + const classes = useStyles(); + + useEffect(() => { + setRows( + items.map(s => ({ + ...s, + id: s.noteNumber + })) + ); + }, [items]); + + const handleSelectRow = selected => { + setSelectedRows(rows.filter(r => selected.includes(r.id))); + }; + + const columns = [ + { + headerName: '#', + field: 'noteNumber', + width: 100 + }, + { + headerName: 'Part', + field: 'partNumber', + width: 150 + }, + { + headerName: 'Created', + field: 'dateCreated', + width: 150 + }, + { + headerName: 'Supplier', + field: 'supplierName', + width: 250 + }, + { + headerName: 'Qty', + field: 'orderQty', + width: 100 + }, + { + headerName: 'Order No', + field: 'originalOrderNumber', + width: 100 + }, + { + headerName: 'Returns Order', + field: 'returnsOrderNumber', + width: 200 + }, + { + headerName: 'Net Total', + field: 'netTotal', + width: 200 + }, + { + headerName: 'Comments', + field: 'notes', + width: 400 + } + ]; + return ( + + dispatch(plCreditDebitNoteActions.setSnackbarVisible(false))} + message="Save Successful" + /> + + +
+ setDialogOpen(false)} + > + + +
+ + + + Mark Selected as Closed + + + + + setCloseReason(newValue)} + label="Reason? (optional)" + propertyName="closeReason" + /> + + + + + +
+
+
+ + +
+ setCommentsDialogOpen(false)} + > + + +
+ + + + Edit Comments + + + + + setComments(newValue)} + label="Comments" + propertyName="comments" + /> + + + + + +
+
+
+ + + + </Grid> + {updateError && ( + <Grid item xs={12}> + <ErrorCard + errorMessage={ + updateError?.details?.errors?.[0] || updateError.statusText + } + /> + </Grid> + )} + {itemsLoading || updateLoading ? ( + <Grid item xs={12}> + <Loading /> + </Grid> + ) : ( + <> + {rows && ( + <> + <Grid item xs={12}> + <div style={{ height: 500, width: '100%' }}> + <DataGrid + rows={rows} + columns={columns} + density="standard" + rowHeight={34} + checkboxSelection + onSelectionModelChange={handleSelectRow} + loading={itemsLoading} + hideFooter + filterModel={{ + items: [ + { + columnField: 'supplierName', + operatorValue: 'contains', + value: '' + } + ] + }} + /> + </div> + </Grid> + <Grid item xs={2}> + <Button + style={{ marginTop: '22px' }} + colour="primary" + variant="outlined" + onClick={() => { + setDialogOpen(true); + }} + > + Close Selected + </Button> + </Grid> + <Grid item xs={10} /> + <Grid item xs={4}> + <Button + style={{ marginTop: '22px' }} + colour="primary" + variant="outlined" + disabled={selectedRows.length !== 1} + onClick={() => { + setComments(selectedRows[0].notes); + setCommentsDialogOpen(true); + }} + > + Edit Comments of Selected + </Button> + </Grid> + <Grid item xs={4} /> + <Grid item xs={4}> + <Typography + className={classes.dialog} + variant="h5" + gutterBottom + > + Total Outstanding: £ + {rows.length > 0 + ? rows + .map(r => r.netTotal) + .reduce((a, b) => a + b, 0) + .toFixed(2) + : ''} + </Typography> + </Grid> + </> + )} + </> + )} + </Grid> + </Page> + ); +} + +OpenDebitNotes.propTypes = {}; + +OpenDebitNotes.defaultProps = {}; + +export default OpenDebitNotes; diff --git a/src/Service.Host/client/src/itemTypes.js b/src/Service.Host/client/src/itemTypes.js index 2b7ec5bb7..9f1a95972 100644 --- a/src/Service.Host/client/src/itemTypes.js +++ b/src/Service.Host/client/src/itemTypes.js @@ -122,3 +122,15 @@ export const vendorManagers = new ItemType( export const planners = new ItemType('planners', 'PLANNERS', '/purchasing/suppliers/planners'); export const contacts = new ItemType('contacts', 'CONTACTS', '/purchasing/suppliers/contacts'); + +export const plCreditDebitNote = new ItemType( + 'plCreditDebitNote', + 'PL_CREDIT_DEBIT_NOTE', + '/purchasing/pl-credit-debit-notes' +); + +export const openDebitNotes = new ItemType( + 'plCreditDebitNotes', + 'PL_CREDIT_DEBIT_NOTES', + '/purchasing/open-debit-notes' +); diff --git a/src/Service.Host/client/src/reducers/index.js b/src/Service.Host/client/src/reducers/index.js index 47c13a989..99da5b484 100644 --- a/src/Service.Host/client/src/reducers/index.js +++ b/src/Service.Host/client/src/reducers/index.js @@ -41,6 +41,8 @@ import suppliersWithUnacknowledgedOrders from './suppliersWithUnacknowledgedOrde import unacknowledgedOrdersReport from './unacknowledgedOrdersReport'; import planners from './planners'; import spendByPartReport from './spendByPartReport'; +import plCreditDebitNote from './plCreditDebitNote'; +import openDebitNotes from './openDebitNotes'; const errors = fetchErrorReducer({ ...itemTypes, ...reportTypes }); @@ -67,6 +69,8 @@ const rootReducer = history => partSupplier, partSuppliers, planners, + plCreditDebitNote, + openDebitNotes, preferredSupplierChange, priceChangeReasons, putSupplierOnHold, diff --git a/src/Service.Host/client/src/reducers/openDebitNotes.js b/src/Service.Host/client/src/reducers/openDebitNotes.js new file mode 100644 index 000000000..65902e161 --- /dev/null +++ b/src/Service.Host/client/src/reducers/openDebitNotes.js @@ -0,0 +1,14 @@ +import { collectionStoreFactory } from '@linn-it/linn-form-components-library'; +import { openDebitNotesActionTypes as actionTypes } from '../actions'; +import * as itemTypes from '../itemTypes'; + +const defaultState = { + loading: false, + items: [] +}; + +export default collectionStoreFactory( + itemTypes.openDebitNotes.actionType, + actionTypes, + defaultState +); diff --git a/src/Service.Host/client/src/reducers/plCreditDebitNote.js b/src/Service.Host/client/src/reducers/plCreditDebitNote.js new file mode 100644 index 000000000..589510174 --- /dev/null +++ b/src/Service.Host/client/src/reducers/plCreditDebitNote.js @@ -0,0 +1,11 @@ +import { itemStoreFactory } from '@linn-it/linn-form-components-library'; +import { plCreditDebitNoteActionTypes as actionTypes } from '../actions'; +import * as itemTypes from '../itemTypes'; + +const defaultState = { + loading: false, + item: null, + editStatus: 'view' +}; + +export default itemStoreFactory(itemTypes.plCreditDebitNote.actionType, actionTypes, defaultState); diff --git a/src/Service/Modules/PlCreditDebitNotesModule.cs b/src/Service/Modules/PlCreditDebitNotesModule.cs new file mode 100644 index 000000000..d9d1c90a0 --- /dev/null +++ b/src/Service/Modules/PlCreditDebitNotesModule.cs @@ -0,0 +1,52 @@ +namespace Linn.Purchasing.Service.Modules +{ + using System.Threading.Tasks; + + using Carter; + using Carter.ModelBinding; + using Carter.Request; + using Carter.Response; + + using Linn.Common.Facade; + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + using Linn.Purchasing.Resources; + using Linn.Purchasing.Service.Extensions; + + using Microsoft.AspNetCore.Http; + + public class PlCreditDebitNotesModule : CarterModule + { + private readonly IFacadeResourceFilterService<PlCreditDebitNote, int, PlCreditDebitNoteResource, PlCreditDebitNoteResource, PlCreditDebitNoteResource> service; + + public PlCreditDebitNotesModule( + IFacadeResourceFilterService<PlCreditDebitNote, int, PlCreditDebitNoteResource, PlCreditDebitNoteResource, PlCreditDebitNoteResource> service) + { + this.service = service; + this.Get("/purchasing/open-debit-notes", this.GetOpenDebitNotes); + this.Put("/purchasing/pl-credit-debit-notes/{id}", this.UpdateDebitNote); + } + + private async Task GetOpenDebitNotes(HttpRequest req, HttpResponse res) + { + var results = this.service.FilterBy(new PlCreditDebitNoteResource + { + DateClosed = null, + NoteType = "D" + }); + + await res.Negotiate(results); + } + + private async Task UpdateDebitNote(HttpRequest req, HttpResponse res) + { + var resource = await req.Bind<PlCreditDebitNoteResource>(); + resource.ClosedBy = req.HttpContext.User.GetEmployeeNumber(); + var result = this.service.Update( + req.RouteValues.As<int>("id"), + resource, + req.HttpContext.GetPrivileges()); + + await res.Negotiate(result); + } + } +} diff --git a/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/ContextBase.cs b/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/ContextBase.cs new file mode 100644 index 000000000..42e778a71 --- /dev/null +++ b/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/ContextBase.cs @@ -0,0 +1,59 @@ +namespace Linn.Purchasing.Integration.Tests.PlCreditDebitNotesModuleTests +{ + using System.Net.Http; + + using Linn.Common.Facade; + using Linn.Common.Persistence; + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + using Linn.Purchasing.Facade.ResourceBuilders; + using Linn.Purchasing.Facade.Services; + using Linn.Purchasing.IoC; + using Linn.Purchasing.Resources; + using Linn.Purchasing.Service.Modules; + + using Microsoft.Extensions.DependencyInjection; + + using NSubstitute; + + using NUnit.Framework; + + public class ContextBase + { + protected HttpClient Client { get; set; } + + protected HttpResponseMessage Response { get; set; } + + protected ITransactionManager MockTransactionManager { get; private set; } + + protected IRepository<PlCreditDebitNote, int> MockPlCreditDebitNoteRepository { get; private set; } + + protected IPlCreditDebitNoteService MockDomainService { get; private set; } + + protected IFacadeResourceFilterService<PlCreditDebitNote, int, PlCreditDebitNoteResource, + PlCreditDebitNoteResource, PlCreditDebitNoteResource> FacadeService + { + get; private set; + } + + [SetUp] + public void EstablishContext() + { + this.MockPlCreditDebitNoteRepository = Substitute.For<IRepository<PlCreditDebitNote, int>>(); + this.MockTransactionManager = Substitute.For<ITransactionManager>(); + this.MockDomainService = Substitute.For<IPlCreditDebitNoteService>(); + this.FacadeService = new PlCreditDebitNoteFacadeService( + this.MockPlCreditDebitNoteRepository, + this.MockTransactionManager, + new PlCreditDebitNoteResourceBuilder(), + this.MockDomainService); + + this.Client = TestClient.With<PlCreditDebitNotesModule>( + services => + { + services.AddSingleton(this.FacadeService); + services.AddHandlers(); + }, + FakeAuthMiddleware.EmployeeMiddleware); + } + } +} diff --git a/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/WhenClosing.cs b/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/WhenClosing.cs new file mode 100644 index 000000000..11020eb06 --- /dev/null +++ b/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/WhenClosing.cs @@ -0,0 +1,86 @@ +namespace Linn.Purchasing.Integration.Tests.PlCreditDebitNotesModuleTests +{ + using System; + using System.Collections.Generic; + using System.Net; + + using FluentAssertions; + + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + using Linn.Purchasing.Integration.Tests.Extensions; + using Linn.Purchasing.Resources; + + using NSubstitute; + + using NUnit.Framework; + + public class WhenClosing : ContextBase + { + private PlCreditDebitNoteResource resource; + + private PlCreditDebitNote note; + + private PlCreditDebitNote closed; + + [SetUp] + public void SetUp() + { + this.resource = new PlCreditDebitNoteResource + { + NoteNumber = 1, + Close = true, + ReasonClosed = "REASON", + ClosedBy = 22087 + }; + this.note = new PlCreditDebitNote + { + NoteNumber = this.resource.NoteNumber, + DateCreated = DateTime.UnixEpoch + }; + + this.closed = new PlCreditDebitNote + { + NoteNumber = this.resource.NoteNumber, + DateClosed = DateTime.Today, + ReasonClosed = this.resource.ReasonClosed, + DateCreated = DateTime.UnixEpoch + }; + + this.MockPlCreditDebitNoteRepository.FindById(1).Returns(this.note); + + this.MockDomainService.CloseDebitNote( + this.note, + this.resource.ReasonClosed, + (int)this.resource.ClosedBy, + Arg.Any<IEnumerable<string>>()) + .Returns(this.closed); + this.Response = this.Client.Put( + $"/purchasing/pl-credit-debit-notes/{this.resource.NoteNumber}", + this.resource, + with => + { + with.Accept("application/json"); + }).Result; + } + + [Test] + public void ShouldReturnOk() + { + this.Response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Test] + public void ShouldReturnJsonContentType() + { + this.Response.Content.Headers.ContentType.Should().NotBeNull(); + this.Response.Content.Headers.ContentType?.ToString().Should().Be("application/json"); + } + + [Test] + public void ShouldReturnJsonBody() + { + var res = this.Response.DeserializeBody<PlCreditDebitNoteResource>(); + res.Should().NotBeNull(); + } + } +} diff --git a/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/WhenGettingAll.cs b/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/WhenGettingAll.cs new file mode 100644 index 000000000..fe04b09ec --- /dev/null +++ b/tests/Integration/Integration.Tests/PlCreditDebitNotesModuleTests/WhenGettingAll.cs @@ -0,0 +1,61 @@ +namespace Linn.Purchasing.Integration.Tests.PlCreditDebitNotesModuleTests +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Linq.Expressions; + using System.Net; + + using FluentAssertions; + + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + using Linn.Purchasing.Integration.Tests.Extensions; + using Linn.Purchasing.Resources; + + using NSubstitute; + + using NUnit.Framework; + + public class WhenGettingAll : ContextBase + { + [SetUp] + public void SetUp() + { + this.MockPlCreditDebitNoteRepository.FilterBy(Arg.Any<Expression<Func<PlCreditDebitNote, bool>>>()) + .Returns(new List<PlCreditDebitNote> + { + new PlCreditDebitNote { NoteNumber = 1 }, + new PlCreditDebitNote { NoteNumber = 2 }, + }.AsQueryable()); + this.Response = this.Client.Get( + "/purchasing/open-debit-notes", + with => + { + with.Accept("application/json"); + }).Result; + } + + [Test] + public void ShouldReturnOk() + { + this.Response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Test] + public void ShouldReturnJsonContentType() + { + this.Response.Content.Headers.ContentType.Should().NotBeNull(); + this.Response.Content.Headers.ContentType?.ToString().Should().Be("application/json"); + } + + [Test] + public void ShouldReturnJsonBody() + { + var resources = this.Response.DeserializeBody<IEnumerable<PlCreditDebitNoteResource>>()?.ToArray(); + resources.Should().NotBeNull(); + resources.Should().HaveCount(2); + resources.Should().Contain(a => a.NoteNumber == 1); + resources.Should().Contain(a => a.NoteNumber == 2); + } + } +} diff --git a/tests/Unit/Domain.LinnApps.Tests/Domain.LinnApps.Tests.csproj b/tests/Unit/Domain.LinnApps.Tests/Domain.LinnApps.Tests.csproj index 482fedf41..d8694a8c9 100644 --- a/tests/Unit/Domain.LinnApps.Tests/Domain.LinnApps.Tests.csproj +++ b/tests/Unit/Domain.LinnApps.Tests/Domain.LinnApps.Tests.csproj @@ -20,4 +20,8 @@ <ProjectReference Include="..\..\..\src\Domain.LinnApps\Domain.LinnApps.csproj" /> </ItemGroup> + <ItemGroup> + <Folder Include="PlCreditDebitNotesTests\" /> + </ItemGroup> + </Project> diff --git a/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/ContextBase.cs b/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/ContextBase.cs new file mode 100644 index 000000000..2600405d1 --- /dev/null +++ b/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/ContextBase.cs @@ -0,0 +1,23 @@ +namespace Linn.Purchasing.Domain.LinnApps.Tests.PlCreditDebitNotesTests +{ + using Linn.Common.Authorisation; + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + + using NSubstitute; + + using NUnit.Framework; + + public class ContextBase + { + protected IPlCreditDebitNoteService Sut { get; private set; } + + protected IAuthorisationService MockAuthService { get; private set; } + + [SetUp] + public void SetUpContext() + { + this.MockAuthService = Substitute.For<IAuthorisationService>(); + this.Sut = new PlCreditDebitNoteService(this.MockAuthService); + } + } +} diff --git a/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/WhenClosingADebitNote.cs b/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/WhenClosingADebitNote.cs new file mode 100644 index 000000000..58cd20123 --- /dev/null +++ b/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/WhenClosingADebitNote.cs @@ -0,0 +1,44 @@ +namespace Linn.Purchasing.Domain.LinnApps.Tests.PlCreditDebitNotesTests +{ + using System; + using System.Collections.Generic; + + using FluentAssertions; + + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + + using NSubstitute; + + using NUnit.Framework; + + public class WhenClosingADebitNote : ContextBase + { + private PlCreditDebitNote note; + + private PlCreditDebitNote result; + + [SetUp] + public void SetUp() + { + this.note = new PlCreditDebitNote { DateCreated = DateTime.UnixEpoch, NoteNumber = 1 }; + this.MockAuthService.HasPermissionFor( + AuthorisedAction.PlCreditDebitNoteClose, + Arg.Is<List<string>>(x => x.Contains(AuthorisedAction.PlCreditDebitNoteClose))).Returns(true); + + this.result = this.Sut.CloseDebitNote( + this.note, + "REASON", + 33087, + new List<string> { AuthorisedAction.PlCreditDebitNoteClose }); + } + + [Test] + public void ShouldReturnClosed() + { + this.result.NoteNumber.Should().Be(1); + this.result.DateClosed.Should().Be(DateTime.Today); + this.result.ReasonClosed.Should().Be("REASON"); + this.result.ClosedBy.Should().Be(33087); + } + } +} diff --git a/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/WhenClosingADebitNoteAndUserNotAuthorised.cs b/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/WhenClosingADebitNoteAndUserNotAuthorised.cs new file mode 100644 index 000000000..57894dd41 --- /dev/null +++ b/tests/Unit/Domain.LinnApps.Tests/PlCreditDebitNotesTests/WhenClosingADebitNoteAndUserNotAuthorised.cs @@ -0,0 +1,42 @@ +namespace Linn.Purchasing.Domain.LinnApps.Tests.PlCreditDebitNotesTests +{ + using System; + using System.Collections.Generic; + + using FluentAssertions; + + using Linn.Purchasing.Domain.LinnApps.Exceptions; + using Linn.Purchasing.Domain.LinnApps.PurchaseOrders; + + using NSubstitute; + + using NUnit.Framework; + + public class WhenClosingADebitNoteAndUserNotAuthorised : ContextBase + { + private Action action; + + private PlCreditDebitNote note; + + [SetUp] + public void SetUp() + { + this.note = new PlCreditDebitNote { DateCreated = DateTime.UnixEpoch, NoteNumber = 1 }; + this.MockAuthService.HasPermissionFor( + AuthorisedAction.PlCreditDebitNoteClose, + Arg.Is<List<string>>(x => x.Contains(AuthorisedAction.PlCreditDebitNoteClose))).Returns(false); + + this.action = () => this.Sut.CloseDebitNote( + this.note, + "REASON", + 33087, + new List<string> { AuthorisedAction.PlCreditDebitNoteClose }); + } + + [Test] + public void ShouldThrowUnauthorisedActionException() + { + this.action.Should().Throw<UnauthorisedActionException>(); + } + } +}