diff --git a/server/AyaNova/Controllers/PartAssemblyController.cs b/server/AyaNova/Controllers/PartAssemblyController.cs index f9e280c1..92f7f2fb 100644 --- a/server/AyaNova/Controllers/PartAssemblyController.cs +++ b/server/AyaNova/Controllers/PartAssemblyController.cs @@ -8,16 +8,6 @@ using AyaNova.Models; using AyaNova.Api.ControllerHelpers; using AyaNova.Biz; -/* -Ok, need to figure this one out. -Is a partassembly posted with all it's collection of parts or are those two separate ops? -It's obvs more efficient to post it all at once on creation -From users' point of view it's all one object -Maybe it should act like it's one big collection as the partassembly is unlikely to have a huge number of parts ever anyway - -so the todo is to make the assemblyitem part of a collection under assembly that gets CRUD together -https://stackoverflow.com/questions/46517584/how-to-add-a-parent-record-with-its-children-records-in-ef-core#46615455 -*/ namespace AyaNova.Api.Controllers { @@ -107,7 +97,7 @@ namespace AyaNova.Api.Controllers return StatusCode(403, new ApiNotAuthorizedResponse()); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); - var o = await biz.GetAsync(id,true,true); + var o = await biz.GetAsync(id, true, true); if (o == null) return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); return Ok(ApiOkResponse.Response(o)); } diff --git a/server/AyaNova/Controllers/PartController.cs b/server/AyaNova/Controllers/PartController.cs index 1a0a7cc1..c9f984ec 100644 --- a/server/AyaNova/Controllers/PartController.cs +++ b/server/AyaNova/Controllers/PartController.cs @@ -204,6 +204,56 @@ namespace AyaNova.Api.Controllers return Ok(ApiOkResponse.Response(o)); } + /// + /// Get stock levels for part + /// + /// + /// Array of part stock levels + [HttpGet("stock-levels/{id}")] + public async Task GetPartStockLevels([FromRoute] long id) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Part)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await ct.PartStockLevel.AsNoTracking().Where(z => z.PartId == id).OrderBy(z => z.PartWarehouseId).ToListAsync(); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Put (update) stock levels for part + /// + /// array of part stock levels + ///PartId + /// + [HttpPut("stock-levels/{id}")] + public async Task PutPartStockLevels([FromRoute] long id, [FromBody] List partStockLevels) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + PartBiz biz = PartBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + + + var o = await biz.PutStockLevelsAsync(id, partStockLevels); + if (o == null) + { + if (biz.Errors.Exists(z => z.Code == ApiErrorCode.CONCURRENCY_CONFLICT)) + return StatusCode(409, new ApiErrorResponse(biz.Errors)); + else + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + return Ok(ApiOkResponse.Response(o)); + } + + //------------ diff --git a/server/AyaNova/biz/PartBiz.cs b/server/AyaNova/biz/PartBiz.cs index 84bcabfa..ac8bff4e 100644 --- a/server/AyaNova/biz/PartBiz.cs +++ b/server/AyaNova/biz/PartBiz.cs @@ -232,6 +232,58 @@ namespace AyaNova.Biz } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE STOCK LEVELS + // + internal async Task> PutStockLevelsAsync(long id, List putPartStockLevels) + { + //Fixup serials + + int nAdded = 0; + int nRemoved = 0; + var ExistingStockLevels = await ct.PartStockLevel.Where(z => z.PartId == id).ToListAsync(); + + //Remove any that should not be there anymore + foreach (PartStockLevel existingPS in ExistingStockLevels) + { + if (!putPartStockLevels.Any(z => z.PartWarehouseId==existingPS.PartWarehouseId)) + { + //no longer in the collection so ditch it + ct.PartStockLevel.Remove(existingPS); + nRemoved++; + } + } + + //Add any new ones + foreach (PartStockLevel putPS in putPartStockLevels) + { + if (!ExistingStockLevels.Any(z => z.PartWarehouseId==putPS.PartWarehouseId)) + { + ct.PartStockLevel.Add(new PartStockLevel() { PartWarehouseId = putPS.PartWarehouseId, PartId = id }); + nAdded++; + } + } + + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await ExistsAsync(id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Modified, $"LT:PartSerialNumbersAvailable change (+{nAdded}, -{nRemoved})"), ct); + + return await ct.PartSerial.Where(z => z.PartId == id).OrderBy(z => z.Serial).Select(z => z.Serial).ToListAsync(); + } + //////////////////////////////////////////////////////////////////////////////////////////////// //SEARCH // diff --git a/server/AyaNova/models/AyContext.cs b/server/AyaNova/models/AyContext.cs index 3ef081b1..1aed71d4 100644 --- a/server/AyaNova/models/AyContext.cs +++ b/server/AyaNova/models/AyContext.cs @@ -42,6 +42,7 @@ namespace AyaNova.Models public virtual DbSet Part { get; set; } public virtual DbSet PartInventory { get; set; } public virtual DbSet PartWarehouse { get; set; } + public virtual DbSet PartStockLevel { get; set; } public virtual DbSet PartSerial { get; set; } public virtual DbSet PartAssembly { get; set; } public virtual DbSet PartAssemblyItem { get; set; } diff --git a/server/AyaNova/models/PartStockLevel.cs b/server/AyaNova/models/PartStockLevel.cs new file mode 100644 index 00000000..381bb6ef --- /dev/null +++ b/server/AyaNova/models/PartStockLevel.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace AyaNova.Models +{ + //for specific parts only + //if not in this table then a part has no minimum level + //NOTE: called this specifically partstocklevel and not restock level because it could be expanded to include reorder, danger, maximum levels + //if we want to implement fully inventory management down the road (DTR) + /* + This article throws light upon the four major types of stock levels of inventory. The types are: 1. Minimum Level 2. Maximum Level 3. Danger Level 4. Average Stock Level. + https://www.yourarticlelibrary.com/material-management/inventory-control-material-management/4-major-types-of-stock-levels-of-inventory-with-formula/90394 + */ + public class PartStockLevel + { + public long Id { get; set; } + public uint Concurrency { get; set; } + + [Required] + public long PartWarehouseId { get; set; } + [Required] + public long PartId { get; set; } + [Required] + public decimal MinimumQuantity { get; set; } + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/resource/de.json b/server/AyaNova/resource/de.json index e671b0e6..3f7c95c5 100644 --- a/server/AyaNova/resource/de.json +++ b/server/AyaNova/resource/de.json @@ -2142,5 +2142,6 @@ "PartInventoryTransactionEntryDate":"Datum", "PartInventoryTransactionSource":"Transaktionsquelle", "PartInventoryTransactionQuantity":"Menge", - "PartInventoryBalance":"Menge zur Hand" + "PartInventoryBalance":"Menge zur Hand", + "PartStockingLevels":"Mindestbestand an Teilen" } \ No newline at end of file diff --git a/server/AyaNova/resource/en.json b/server/AyaNova/resource/en.json index 89465865..85746ebd 100644 --- a/server/AyaNova/resource/en.json +++ b/server/AyaNova/resource/en.json @@ -2142,6 +2142,7 @@ "PartInventoryTransactionEntryDate":"Date", "PartInventoryTransactionSource":"Transaction source", "PartInventoryTransactionQuantity":"Quantity", - "PartInventoryBalance":"On hand quantity" + "PartInventoryBalance":"On hand quantity", + "PartStockingLevels":"Part stocking levels" } \ No newline at end of file diff --git a/server/AyaNova/resource/es.json b/server/AyaNova/resource/es.json index faeb3d3f..88ff3ca7 100644 --- a/server/AyaNova/resource/es.json +++ b/server/AyaNova/resource/es.json @@ -2142,5 +2142,6 @@ "PartInventoryTransactionEntryDate":"Fecha", "PartInventoryTransactionSource":"Origen de la transacción", "PartInventoryTransactionQuantity":"Cantidad", - "PartInventoryBalance":"Cantidad en mano" + "PartInventoryBalance":"Cantidad en mano", + "PartStockingLevels":"Niveles de existencias de piezas" } \ No newline at end of file diff --git a/server/AyaNova/resource/fr.json b/server/AyaNova/resource/fr.json index 50ee8a8d..4b240ab3 100644 --- a/server/AyaNova/resource/fr.json +++ b/server/AyaNova/resource/fr.json @@ -2142,5 +2142,6 @@ "PartInventoryTransactionEntryDate":"Date", "PartInventoryTransactionSource":"Source de transaction", "PartInventoryTransactionQuantity":"Quantité", - "PartInventoryBalance":"Quantité disponible" + "PartInventoryBalance":"Quantité disponible", + "PartStockingLevels":"Niveaux de stockage des pièces" } \ No newline at end of file diff --git a/server/AyaNova/util/AySchema.cs b/server/AyaNova/util/AySchema.cs index 92bd295a..c6d91784 100644 --- a/server/AyaNova/util/AySchema.cs +++ b/server/AyaNova/util/AySchema.cs @@ -675,15 +675,15 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); //PARTSERIAL await ExecQueryAsync("CREATE TABLE apartserial (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, serial TEXT NOT NULL, " + - "partid BIGINT NOT NULL REFERENCES apart on delete cascade, CONSTRAINT unq_partserialpart UNIQUE (partid, serial) )");//ensure not duplicate partid/serial combo + "partid BIGINT NOT NULL REFERENCES apart ON DELETE CASCADE, CONSTRAINT unq_partserialpart UNIQUE (partid, serial) )");//ensure not duplicate partid/serial combo //PARTASSEMBLY await ExecQueryAsync("CREATE TABLE apartassembly (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL UNIQUE, active BOOL NOT NULL, " + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY )"); //PARTASSEMBLYITEM - await ExecQueryAsync("CREATE TABLE apartassemblyitem (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, partassemblyid BIGINT NOT NULL REFERENCES apartassembly on delete cascade, " + - "partid BIGINT NOT NULL REFERENCES apart, quantity DECIMAL(19,4) NOT NULL default 1, " + + await ExecQueryAsync("CREATE TABLE apartassemblyitem (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, partassemblyid BIGINT NOT NULL REFERENCES apartassembly ON DELETE CASCADE, " + + "partid BIGINT NOT NULL REFERENCES apart ON DELETE CASCADE, quantity DECIMAL(19,4) NOT NULL default 1, " + "CONSTRAINT unq_partassemblypart UNIQUE (partid, partassemblyid) " +//ensure no duplicate parts in the same assembly ")"); // await ExecQueryAsync("CREATE INDEX idx_apartassemblyitem_partid ON apartassemblyitem(partid)"); @@ -711,6 +711,13 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); //see what if any index tuning will help with a huge db of realistic data doing most common ops //await ExecQueryAsync("CREATE INDEX idx_PartInventory_SourceId_SourceType ON apartinventory (sourceid, sourcetype);"); + //PARTSTOCKLEVEL + await ExecQueryAsync("CREATE TABLE apartstocklevel (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, partwarehouseid BIGINT NOT NULL REFERENCES apartwarehouse ON DELETE CASCADE, " + + "partid BIGINT NOT NULL REFERENCES apart ON DELETE CASCADE, minimumquantity DECIMAL(19,4) NOT NULL default 1, " + + "CONSTRAINT unq_partstocklevel_part_warehouse UNIQUE (partid, partwarehouseid) " +//ensure no duplicates + ")"); + + //PROJECT await ExecQueryAsync("CREATE TABLE aproject (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL UNIQUE, active BOOL NOT NULL, " + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY, " + @@ -856,7 +863,7 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); "deliveryaddress TEXT, linkreportid BIGINT NOT NULL, tags VARCHAR(255) ARRAY)"); await ExecQueryAsync("CREATE TABLE anotifyevent (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, created TIMESTAMP NOT NULL, " + - "ayatype INTEGER NOT NULL, objectid BIGINT NOT NULL, name TEXT NOT NULL, eventtype INTEGER NOT NULL, notifysubscriptionid BIGINT NOT NULL REFERENCES anotifysubscription(id) on delete cascade, " + + "ayatype INTEGER NOT NULL, objectid BIGINT NOT NULL, name TEXT NOT NULL, eventtype INTEGER NOT NULL, notifysubscriptionid BIGINT NOT NULL REFERENCES anotifysubscription(id) ON DELETE CASCADE, " + "userid BIGINT NOT NULL REFERENCES auser (id), eventdate TIMESTAMP NOT NULL, message TEXT)"); //these fields were in here but seem to not be required so commented out for now, see notifyevent model for deets but //basically remove this comment once certain don't need these fields (close to release or after) @@ -865,7 +872,7 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); await ExecQueryAsync("CREATE TABLE anotification (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, userid BIGINT NOT NULL REFERENCES auser (id), " + "created TIMESTAMP NOT NULL, ayatype INTEGER NOT NULL, objectid BIGINT NOT NULL, name TEXT NOT NULL, eventtype INTEGER NOT NULL, " + - "notifysubscriptionid BIGINT NOT NULL REFERENCES anotifysubscription(id) on delete cascade, message TEXT, fetched BOOL NOT NULL)"); + "notifysubscriptionid BIGINT NOT NULL REFERENCES anotifysubscription(id) ON DELETE CASCADE, message TEXT, fetched BOOL NOT NULL)"); await ExecQueryAsync("CREATE TABLE anotifydeliverylog (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, processed TIMESTAMP NOT NULL, " + "ayatype INTEGER NOT NULL, objectid BIGINT NOT NULL, eventtype INTEGER NOT NULL, notifysubscriptionid BIGINT NOT NULL, idvalue BIGINT NOT NULL, " +