From 2756b2578edd158bf9f304392cdfb5bf752804f5 Mon Sep 17 00:00:00 2001 From: John Cardinal Date: Fri, 23 Jul 2021 23:31:43 +0000 Subject: [PATCH] --- server/AyaNova/Controllers/QuoteController.cs | 1382 +++- .../AyaNova/Controllers/SearchController.cs | 20 +- .../Controllers/WorkOrderController.cs | 6 +- server/AyaNova/DataList/QuoteDataList.cs | 359 + server/AyaNova/biz/BizObjectFactory.cs | 25 +- server/AyaNova/biz/BizRoles.cs | 29 +- server/AyaNova/biz/QuoteBiz.cs | 5983 ++++++++++++++++- server/AyaNova/biz/WorkOrderBiz.cs | 48 +- server/AyaNova/models/QuoteItem.cs | 4 +- .../models/dto/ParentAndChildItemId.cs | 8 + .../AyaNova/models/dto/WorkorderAndItemId.cs | 8 - server/AyaNova/util/AySchema.cs | 95 +- server/AyaNova/util/DbUtil.cs | 37 +- 13 files changed, 7631 insertions(+), 373 deletions(-) create mode 100644 server/AyaNova/DataList/QuoteDataList.cs create mode 100644 server/AyaNova/models/dto/ParentAndChildItemId.cs delete mode 100644 server/AyaNova/models/dto/WorkorderAndItemId.cs diff --git a/server/AyaNova/Controllers/QuoteController.cs b/server/AyaNova/Controllers/QuoteController.cs index 273c1b5d..fb8b1b99 100644 --- a/server/AyaNova/Controllers/QuoteController.cs +++ b/server/AyaNova/Controllers/QuoteController.cs @@ -3,19 +3,19 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Authorization; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using AyaNova.Models; using AyaNova.Api.ControllerHelpers; using AyaNova.Biz; - +using Microsoft.EntityFrameworkCore; +using System.Linq; namespace AyaNova.Api.Controllers { [ApiController] [ApiVersion("8.0")] - [Route("api/v{version:apiVersion}/quote")] + [Route("api/v{version:apiVersion}/workorder")] [Produces("application/json")] [Authorize] public class QuoteController : ControllerBase @@ -38,186 +38,1308 @@ namespace AyaNova.Api.Controllers serverState = apiServerState; } + + /* + ██████╗ ██╗ ██╗ ██████╗ ████████╗███████╗ + ██╔═══██╗██║ ██║██╔═══██╗╚══██╔══╝██╔════╝ + ██║ ██║██║ ██║██║ ██║ ██║ █████╗ + ██║▄▄ ██║██║ ██║██║ ██║ ██║ ██╔══╝ + ╚██████╔╝╚██████╔╝╚██████╔╝ ██║ ███████╗ + ╚══▀▀═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ + */ + + #region Quote top level routes + /// + /// Create Quote + /// + /// Quote - top level only, no descendants + /// From route path + /// Quote + [HttpPost] + public async Task PostQuote([FromBody] Quote newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, biz.BizType)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + + if (newObject.Items.Count > 0) + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "generalerror", "Work order POST route accepts header only; POST Work order descendants separately")); + + Quote o = await biz.QuoteCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuote), new { id = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + // /// + // /// Duplicate Quote + // /// (Wiki and Attachments are not duplicated) + // /// + // /// Source object id + // /// From route path + // /// Quote + // [HttpPost("duplicate/{id}")] + // public async Task DuplicateQuote([FromRoute] long id, ApiVersion apiVersion) + // { + // if (!serverState.IsOpen) + // return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + // QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + // if (!Authorized.HasCreateRole(HttpContext.Items, biz.BizType)) + // return StatusCode(403, new ApiNotAuthorizedResponse()); + // if (!ModelState.IsValid) + // return BadRequest(new ApiErrorResponse(ModelState)); + // Quote o = await biz.QuoteDuplicateAsync(id); + // if (o == null) + // return BadRequest(new ApiErrorResponse(biz.Errors)); + // else + // return CreatedAtAction(nameof(QuoteController.GetQuote), new { id = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + // } + /// - /// Get full Quote object + /// Get Quote /// /// - /// A single Quote + /// Quote [HttpGet("{id}")] public async Task GetQuote([FromRoute] long id) { if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); - - //Instantiate the business object handler QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); - - //NOTE: This is the first check and often the only check but in some cases with some objects this will also need to check biz object rules if (!Authorized.HasReadFullRole(HttpContext.Items, biz.BizType)) return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.QuoteGetAsync(id, true); + if (o == null) return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update Quote + /// + /// + /// Quote - top level only, no Items or other descendants + /// Updated work order header + [HttpPut] + public async Task PutQuote([FromBody] Quote updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); - var o = await biz.GetAsync(id); + if (updatedObject.Items.Count > 0) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "generalerror", "Work order PUT route accepts header only; PUT Work order descendants separately")); + } + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.QuotePutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete Quote + /// + /// + /// NoContent + [HttpDelete("{id}")] + public async Task DeleteQuote([FromRoute] long id) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.Quote) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.QuoteDeleteAsync(id)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + + + /// + /// Get Quote id from work order serial number + /// + /// + /// Quote + [HttpGet("id-from-number/{number}")] + public async Task GetQuoteIdFromNumber([FromRoute] long number) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Quote)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await ct.Quote.AsNoTracking() + .Where(z => z.Serial == number) + .Select(z => z.Id) + .SingleOrDefaultAsync(); + if (o == 0) return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + + #endregion QuoteTopLevel routes + + + + /* + + ███████╗████████╗ █████╗ ████████╗███████╗███████╗ + ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝██╔════╝ + ███████╗ ██║ ███████║ ██║ █████╗ ███████╗ + ╚════██║ ██║ ██╔══██║ ██║ ██╔══╝ ╚════██║ + ███████║ ██║ ██║ ██║ ██║ ███████╗███████║ + ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝ + + */ + + + #region QuoteState + /// + /// Create QuoteState + /// + /// QuoteState + /// + /// QuoteState object + [HttpPost("states")] + public async Task PostQuoteState([FromBody] QuoteState newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteStatus) || biz.UserIsSubContractorFull || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteState o = await biz.StateCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteState), new { QuoteStateId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteState object + /// + /// + /// A single QuoteState + [HttpGet("states/{QuoteStateId}")] + public async Task GetQuoteState([FromRoute] long QuoteStateId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteStatus)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.StateGetAsync(QuoteStateId); if (o == null) return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } - // NOTE: HERE would be the second check of biz rules before returning the object - // in cases where there is also a business rule to affect retrieval on top of basic rights + + + #endregion workorderstate + + + + + /* + ██╗████████╗███████╗███╗ ███╗███████╗ + ██║╚══██╔══╝██╔════╝████╗ ████║██╔════╝ + ██║ ██║ █████╗ ██╔████╔██║███████╗ + ██║ ██║ ██╔══╝ ██║╚██╔╝██║╚════██║ + ██║ ██║ ███████╗██║ ╚═╝ ██║███████║ + ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝ + */ + + #region QuoteItem + /// + /// Create QuoteItem + /// + /// QuoteItem - no descendants + /// + /// QuoteItem object + [HttpPost("items")] + public async Task PostQuoteItem([FromBody] QuoteItem newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItem) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItem o = await biz.ItemCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItem), new { QuoteItemId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItem object + /// + /// + /// A single QuoteItem + [HttpGet("items/{QuoteItemId}")] + public async Task GetQuoteItem([FromRoute] long QuoteItemId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItem) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.ItemGetAsync(QuoteItemId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); return Ok(ApiOkResponse.Response(o)); } /// - /// Update Quote - /// - /// - /// - /// - [HttpPut("{id}")] - public async Task PutQuote([FromRoute] long id, [FromBody] Quote inObj) + /// Update QuoteItem + /// + /// + /// QuoteItem - top level only, no descendants + /// New concurrency token + [HttpPut("items/")] + public async Task PutQuoteItem([FromBody] QuoteItem updatedObject) { if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); - if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); - - //Instantiate the business object handler QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); - - var o = await biz.GetAsync(id, false); - if (o == null) - return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); - - if (!Authorized.HasModifyRole(HttpContext.Items, biz.BizType)) + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItem) || biz.UserIsRestrictedType) return StatusCode(403, new ApiNotAuthorizedResponse()); - - try + var o = await biz.ItemPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + if (o == null) { - if (!await biz.PutAsync(o, inObj)) - return BadRequest(new ApiErrorResponse(biz.Errors)); - } - catch (DbUpdateConcurrencyException) - { - if (!await biz.ExistsAsync(id)) - return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + if (biz.Errors.Exists(z => z.Code == ApiErrorCode.CONCURRENCY_CONFLICT)) + return StatusCode(409, new ApiErrorResponse(biz.Errors)); else - return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); + return BadRequest(new ApiErrorResponse(biz.Errors)); } return Ok(ApiOkResponse.Response(new { Concurrency = o.Concurrency })); } - - - - /// - /// Create Quote + /// Delete QuoteItem /// - /// - /// - /// force a workorder number, leave null to autogenerate the next one in sequence (mostly used for import) - /// From route path - /// A created workorder ready to fill out - [HttpPost("Create")] - public async Task PostQuote([FromQuery] long? quoteTemplateId, long? customerId, uint? serial, ApiVersion apiVersion) + /// + /// NoContent + [HttpDelete("items/{QuoteItemId}")] + public async Task DeleteQuoteItem([FromRoute] long QuoteItemId) { if (!serverState.IsOpen) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); - - //Instantiate the business object handler - QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); - - //If a user has change roles - if (!Authorized.HasCreateRole(HttpContext.Items, biz.BizType)) - return StatusCode(403, new ApiNotAuthorizedResponse()); - if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); - - //Create and validate - Quote o = await biz.CreateAsync(quoteTemplateId,customerId, serial); - if (o == null) - return BadRequest(new ApiErrorResponse(biz.Errors)); - else - return CreatedAtAction(nameof(QuoteController.GetQuote), new { id = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); - - - } - - // /// - // /// Duplicate Quote - // /// - // /// Create a duplicate of this items id - // /// From route path - // /// - // [HttpPost("duplicate/{id}")] - // public async Task DuplicateQuote([FromRoute] long id, ApiVersion apiVersion) - // { - // if (!serverState.IsOpen) - // return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); - - // //Instantiate the business object handler - // QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); - - // //If a user has change roles - // if (!Authorized.HasCreateRole(HttpContext.Items, biz.BizType)) - // return StatusCode(403, new ApiNotAuthorizedResponse()); - - // if (!ModelState.IsValid) - // return BadRequest(new ApiErrorResponse(ModelState)); - - // var oSrc = await biz.GetAsync(id, false); - // if (oSrc == null) - // return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); - - // //Create and validate - // Quote o = await biz.DuplicateAsync(oSrc); - // if (o == null) - // return BadRequest(new ApiErrorResponse(biz.Errors)); - // else - // return CreatedAtAction(nameof(QuoteController.GetQuote), new { id = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); - - // } - - - - /// - /// Delete Quote - /// - /// - /// Ok - [HttpDelete("{id}")] - public async Task DeleteQuote([FromRoute] long id) - { - if (!serverState.IsOpen) - return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); - - if (!ModelState.IsValid) - return BadRequest(new ApiErrorResponse(ModelState)); - - //Instantiate the business object handler QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); - - var o = await biz.GetAsync(id, false); - if (o == null) - return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); - - if (!Authorized.HasDeleteRole(HttpContext.Items, biz.BizType)) + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItem) || biz.UserIsRestrictedType) return StatusCode(403, new ApiNotAuthorizedResponse()); - - if (!await biz.DeleteAsync(o)) + if (!await biz.ItemDeleteAsync(QuoteItemId)) return BadRequest(new ApiErrorResponse(biz.Errors)); - return NoContent(); } + #endregion workorderitem + + + /* + ███████╗██╗ ██╗██████╗ ███████╗███╗ ██╗███████╗███████╗███████╗ + ██╔════╝╚██╗██╔╝██╔══██╗██╔════╝████╗ ██║██╔════╝██╔════╝██╔════╝ + █████╗ ╚███╔╝ ██████╔╝█████╗ ██╔██╗ ██║███████╗█████╗ ███████╗ + ██╔══╝ ██╔██╗ ██╔═══╝ ██╔══╝ ██║╚██╗██║╚════██║██╔══╝ ╚════██║ + ███████╗██╔╝ ██╗██║ ███████╗██║ ╚████║███████║███████╗███████║ + ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝╚══════╝ + */ + + #region QuoteItemExpense + + /// + /// Create QuoteItemExpense + /// + /// QuoteItemExpense level only no descendants + /// + /// QuoteItemExpense object (no descendants) + [HttpPost("items/expenses")] + public async Task PostQuoteItemExpense([FromBody] QuoteItemExpense newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemExpense) || biz.UserIsSubContractorFull || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemExpense o = await biz.ExpenseCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemExpense), new { QuoteItemExpenseId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemExpense object + /// + /// + /// A single QuoteItemExpense + [HttpGet("items/expenses/{QuoteItemExpenseId}")] + public async Task GetQuoteItemExpense([FromRoute] long QuoteItemExpenseId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemExpense) || biz.UserIsSubContractorFull || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.ExpenseGetAsync(QuoteItemExpenseId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemExpense + /// + /// + /// QuoteItemExpense - top level only, no descendants + /// Updated Expense item + [HttpPut("items/expenses")] + public async Task PutQuoteItemExpense([FromBody] QuoteItemExpense updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemExpense) || biz.UserIsSubContractorFull || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.ExpensePutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemExpense + /// + /// + /// NoContent + [HttpDelete("items/expenses/{QuoteItemExpenseId}")] + public async Task DeleteQuoteItemExpense([FromRoute] long QuoteItemExpenseId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemExpense) || biz.UserIsSubContractorFull || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.ExpenseDeleteAsync(QuoteItemExpenseId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemExpense + + + /* + ██╗ █████╗ ██████╗ ██████╗ ██████╗ + ██║ ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗ + ██║ ███████║██████╔╝██║ ██║██████╔╝ + ██║ ██╔══██║██╔══██╗██║ ██║██╔══██╗ + ███████╗██║ ██║██████╔╝╚██████╔╝██║ ██║ + ╚══════╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ + + */ + + #region QuoteItemLabor + + /// + /// Create QuoteItemLabor + /// + /// QuoteItemLabor level only no descendants + /// + /// QuoteItemLabor object (no descendants) + [HttpPost("items/labors")] + public async Task PostQuoteItemLabor([FromBody] QuoteItemLabor newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemLabor)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemLabor o = await biz.LaborCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemLabor), new { QuoteItemLaborId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemLabor object + /// + /// + /// A single QuoteItemLabor + [HttpGet("items/labors/{workOrderItemLaborId}")] + public async Task GetQuoteItemLabor([FromRoute] long workOrderItemLaborId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemLabor)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.LaborGetAsync(workOrderItemLaborId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + + /// + /// Update QuoteItemLabor + /// + /// + /// QuoteItemLabor - top level only, no descendants + /// Updated object + [HttpPut("items/labors")] + public async Task PutQuoteItemLabor([FromBody] QuoteItemLabor updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemLabor)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.LaborPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + + /// + /// Delete QuoteItemLabor + /// + /// + /// NoContent + [HttpDelete("items/labors/{workOrderItemLaborId}")] + public async Task DeleteQuoteItemLabor([FromRoute] long workOrderItemLaborId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemLabor)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.LaborDeleteAsync(workOrderItemLaborId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + + #endregion QuoteItemLabor + + + /* + ██╗ ██████╗ █████╗ ███╗ ██╗ + ██║ ██╔═══██╗██╔══██╗████╗ ██║ + ██║ ██║ ██║███████║██╔██╗ ██║ + ██║ ██║ ██║██╔══██║██║╚██╗██║ + ███████╗╚██████╔╝██║ ██║██║ ╚████║ + ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ + */ + + #region QuoteItemLoan + + /// + /// Create QuoteItemLoan + /// + /// QuoteItemLoan level only no descendants + /// + /// QuoteItemLoan object (no descendants) + [HttpPost("items/loans")] + public async Task PostQuoteItemLoan([FromBody] QuoteItemLoan newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemLoan) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemLoan o = await biz.LoanCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemLoan), new { QuoteItemLoanId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemLoan object + /// + /// + /// A single QuoteItemLoan + [HttpGet("items/loans/{QuoteItemLoanId}")] + public async Task GetQuoteItemLoan([FromRoute] long QuoteItemLoanId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemLoan) || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.LoanGetAsync(QuoteItemLoanId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemLoan + /// + /// + /// QuoteItemLoan - top level only, no descendants + /// New concurrency token + [HttpPut("items/loans")] + public async Task PutQuoteItemLoan([FromBody] QuoteItemLoan updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemLoan) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.LoanPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemLoan + /// + /// + /// NoContent + [HttpDelete("items/loans/{QuoteItemLoanId}")] + public async Task DeleteQuoteItemLoan([FromRoute] long QuoteItemLoanId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemLoan) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.LoanDeleteAsync(QuoteItemLoanId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemLoan + + + /* + + ██████╗ ██╗ ██╗████████╗███████╗██╗██████╗ ███████╗ ███████╗███████╗██████╗ ██╗ ██╗██╗ ██████╗███████╗ + ██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██║██╔══██╗██╔════╝ ██╔════╝██╔════╝██╔══██╗██║ ██║██║██╔════╝██╔════╝ + ██║ ██║██║ ██║ ██║ ███████╗██║██║ ██║█████╗ ███████╗█████╗ ██████╔╝██║ ██║██║██║ █████╗ + ██║ ██║██║ ██║ ██║ ╚════██║██║██║ ██║██╔══╝ ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║██║ ██╔══╝ + ╚██████╔╝╚██████╔╝ ██║ ███████║██║██████╔╝███████╗ ███████║███████╗██║ ██║ ╚████╔╝ ██║╚██████╗███████╗ + ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═════╝ ╚══════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═════╝╚══════╝ + + */ + + + #region QuoteItemOutsideService + + /// + /// Create QuoteItemOutsideService + /// + /// QuoteItemOutsideService level only no descendants + /// + /// QuoteItemOutsideService object (no descendants) + [HttpPost("items/outside-services")] + public async Task PostQuoteItemOutsideService([FromBody] QuoteItemOutsideService newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemOutsideService) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemOutsideService o = await biz.OutsideServiceCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemOutsideService), new { QuoteItemOutsideServiceId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemOutsideService object + /// + /// + /// A single QuoteItemOutsideService + [HttpGet("items/outside-services/{QuoteItemOutsideServiceId}")] + public async Task GetQuoteItemOutsideService([FromRoute] long QuoteItemOutsideServiceId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemOutsideService) || biz.UserIsSubContractorFull || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.OutsideServiceGetAsync(QuoteItemOutsideServiceId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemOutsideService + /// + /// + /// QuoteItemOutsideService - top level only, no descendants + /// New concurrency token + [HttpPut("items/outside-services")] + public async Task PutQuoteItemOutsideService([FromBody] QuoteItemOutsideService updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemOutsideService) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.OutsideServicePutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemOutsideService + /// + /// + /// NoContent + [HttpDelete("items/outside-services/{QuoteItemOutsideServiceId}")] + public async Task DeleteQuoteItemOutsideService([FromRoute] long QuoteItemOutsideServiceId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemOutsideService) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.OutsideServiceDeleteAsync(QuoteItemOutsideServiceId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemOutsideService + + + + /* + ██████╗ █████╗ ██████╗ ████████╗███████╗ + ██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝ + ██████╔╝███████║██████╔╝ ██║ ███████╗ + ██╔═══╝ ██╔══██║██╔══██╗ ██║ ╚════██║ + ██║ ██║ ██║██║ ██║ ██║ ███████║ + ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ + */ + + #region QuoteItemPart + + /// + /// Create QuoteItemPart + /// + /// QuoteItemPart level only no descendants + /// + /// QuoteItemPart object (no descendants) + [HttpPost("items/parts")] + public async Task PostQuoteItemPart([FromBody] QuoteItemPart newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemPart) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemPart o = await biz.CreatePartAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemPart), new { QuoteItemPartId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemPart object + /// + /// + /// A single QuoteItemPart + [HttpGet("items/parts/{QuoteItemPartId}")] + public async Task GetQuoteItemPart([FromRoute] long QuoteItemPartId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemPart) || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.PartGetAsync(QuoteItemPartId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemPart + /// + /// + /// QuoteItemPart - top level only, no descendants + /// New concurrency token + [HttpPut("items/parts")] + public async Task PutQuoteItemPart([FromBody] QuoteItemPart updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemPart) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.PartPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemPart + /// + /// + /// NoContent + [HttpDelete("items/parts/{QuoteItemPartId}")] + public async Task DeleteQuoteItemPart([FromRoute] long QuoteItemPartId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemPart) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.PartDeleteAsync(QuoteItemPartId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemPart + + + + /* + ███████╗ ██████╗██╗ ██╗███████╗██████╗ ██╗ ██╗██╗ ███████╗██████╗ ██╗ ██╗███████╗███████╗██████╗ ███████╗ + ██╔════╝██╔════╝██║ ██║██╔════╝██╔══██╗██║ ██║██║ ██╔════╝██╔══██╗ ██║ ██║██╔════╝██╔════╝██╔══██╗██╔════╝ + ███████╗██║ ███████║█████╗ ██║ ██║██║ ██║██║ █████╗ ██║ ██║█████╗██║ ██║███████╗█████╗ ██████╔╝███████╗ + ╚════██║██║ ██╔══██║██╔══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██║ ██║╚════╝██║ ██║╚════██║██╔══╝ ██╔══██╗╚════██║ + ███████║╚██████╗██║ ██║███████╗██████╔╝╚██████╔╝███████╗███████╗██████╔╝ ╚██████╔╝███████║███████╗██║ ██║███████║ + ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝ + */ + + #region QuoteItemScheduledUser + + /// + /// Create QuoteItemScheduledUser + /// + /// QuoteItemScheduledUser level only no descendants + /// + /// QuoteItemScheduledUser object (no descendants) + [HttpPost("items/scheduled-users")] + public async Task PostQuoteItemScheduledUser([FromBody] QuoteItemScheduledUser newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemScheduledUser) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemScheduledUser o = await biz.ScheduledUserCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemScheduledUser), new { QuoteItemScheduledUserId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemScheduledUser object + /// + /// + /// A single QuoteItemScheduledUser + [HttpGet("items/scheduled-users/{QuoteItemScheduledUserId}")] + public async Task GetQuoteItemScheduledUser([FromRoute] long QuoteItemScheduledUserId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemScheduledUser)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.ScheduledUserGetAsync(QuoteItemScheduledUserId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemScheduledUser + /// + /// + /// QuoteItemScheduledUser - top level only, no descendants + /// New concurrency token + [HttpPut("items/scheduled-users")] + public async Task PutQuoteItemScheduledUser([FromBody] QuoteItemScheduledUser updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemScheduledUser) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.ScheduledUserPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemScheduledUser + /// + /// + /// NoContent + [HttpDelete("items/scheduled-users/{QuoteItemScheduledUserId}")] + public async Task DeleteQuoteItemScheduledUser([FromRoute] long QuoteItemScheduledUserId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemScheduledUser) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.ScheduledUserDeleteAsync(QuoteItemScheduledUserId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemScheduledUser + + + /* + ████████╗ █████╗ ███████╗██╗ ██╗ + ╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ + ██║ ███████║███████╗█████╔╝ + ██║ ██╔══██║╚════██║██╔═██╗ + ██║ ██║ ██║███████║██║ ██╗ + ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ + */ + + #region QuoteItemtask + + /// + /// Create QuoteItemTask + /// + /// QuoteItemTask level only no descendants + /// + /// QuoteItemTask object (no descendants) + [HttpPost("items/tasks")] + public async Task PostQuoteItemTask([FromBody] QuoteItemTask newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemTask) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemTask o = await biz.TaskCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemTask), new { QuoteItemTaskId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemTask object + /// + /// + /// A single QuoteItemTask + [HttpGet("items/tasks/{QuoteItemTaskId}")] + public async Task GetQuoteItemTask([FromRoute] long QuoteItemTaskId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemTask)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.TaskGetAsync(QuoteItemTaskId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemTask + /// + /// + /// QuoteItemTask - top level only, no descendants + /// New concurrency token + [HttpPut("items/tasks")] + public async Task PutQuoteItemTask([FromBody] QuoteItemTask updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemTask)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.TaskPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemTask + /// + /// + /// NoContent + [HttpDelete("items/tasks/{QuoteItemTaskId}")] + public async Task DeleteQuoteItemTask([FromRoute] long QuoteItemTaskId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemTask) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.TaskDeleteAsync(QuoteItemTaskId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemTask + + + /* + ████████╗██████╗ █████╗ ██╗ ██╗███████╗██╗ + ╚══██╔══╝██╔══██╗██╔══██╗██║ ██║██╔════╝██║ + ██║ ██████╔╝███████║██║ ██║█████╗ ██║ + ██║ ██╔══██╗██╔══██║╚██╗ ██╔╝██╔══╝ ██║ + ██║ ██║ ██║██║ ██║ ╚████╔╝ ███████╗███████╗ + ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚══════╝ + */ + + #region QuoteItemTravel + + /// + /// Create QuoteItemTravel + /// + /// QuoteItemTravel level only no descendants + /// + /// QuoteItemTravel object (no descendants) + [HttpPost("items/travels")] + public async Task PostQuoteItemTravel([FromBody] QuoteItemTravel newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemTravel)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemTravel o = await biz.TravelCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemTravel), new { QuoteItemTravelId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemTravel object + /// + /// + /// A single QuoteItemTravel + [HttpGet("items/travels/{QuoteItemTravelId}")] + public async Task GetQuoteItemTravel([FromRoute] long QuoteItemTravelId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemTravel)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.TravelGetAsync(QuoteItemTravelId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemTravel + /// + /// + /// QuoteItemTravel - top level only, no descendants + /// New concurrency token + [HttpPut("items/travels")] + public async Task PutQuoteItemTravel([FromBody] QuoteItemTravel updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemTravel)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.TravelPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemTravel + /// + /// + /// NoContent + [HttpDelete("items/travels/{QuoteItemTravelId}")] + public async Task DeleteQuoteItemTravel([FromRoute] long QuoteItemTravelId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemTravel)) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.TravelDeleteAsync(QuoteItemTravelId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemTravel + + + /* + ██╗ ██╗███╗ ██╗██╗████████╗ + ██║ ██║████╗ ██║██║╚══██╔══╝ + ██║ ██║██╔██╗ ██║██║ ██║ + ██║ ██║██║╚██╗██║██║ ██║ + ╚██████╔╝██║ ╚████║██║ ██║ + ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝ + */ + + #region QuoteItemUnit + + /// + /// Create QuoteItemUnit + /// + /// QuoteItemUnit level only no descendants + /// + /// QuoteItemUnit object (no descendants) + [HttpPost("items/units")] + public async Task PostQuoteItemUnit([FromBody] QuoteItemUnit newObject, ApiVersion apiVersion) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasCreateRole(HttpContext.Items, AyaType.QuoteItemUnit) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteItemUnit o = await biz.UnitCreateAsync(newObject); + if (o == null) + return BadRequest(new ApiErrorResponse(biz.Errors)); + else + return CreatedAtAction(nameof(QuoteController.GetQuoteItemUnit), new { QuoteItemUnitId = o.Id, version = apiVersion.ToString() }, new ApiCreatedResponse(o)); + } + + + /// + /// Get QuoteItemUnit object + /// + /// + /// A single QuoteItemUnit + [HttpGet("items/units/{QuoteItemUnitId}")] + public async Task GetQuoteItemUnit([FromRoute] long QuoteItemUnitId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.QuoteItemUnit) || biz.UserIsSubContractorRestricted) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + var o = await biz.UnitGetAsync(QuoteItemUnitId); + if (o == null) + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + return Ok(ApiOkResponse.Response(o)); + } + + /// + /// Update QuoteItemUnit + /// + /// + /// QuoteItemUnit - top level only, no descendants + /// New concurrency token + [HttpPut("items/units")] + public async Task PutQuoteItemUnit([FromBody] QuoteItemUnit updatedObject) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.QuoteItemUnit) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + var o = await biz.UnitPutAsync(updatedObject);//In future may need to return entire object, for now just concurrency token + 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)); + } + + /// + /// Delete QuoteItemUnit + /// + /// + /// NoContent + [HttpDelete("items/units/{QuoteItemUnitId}")] + public async Task DeleteQuoteItemUnit([FromRoute] long QuoteItemUnitId) + { + if (!serverState.IsOpen) + return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); + if (!ModelState.IsValid) + return BadRequest(new ApiErrorResponse(ModelState)); + QuoteBiz biz = QuoteBiz.GetBiz(ct, HttpContext); + if (!Authorized.HasDeleteRole(HttpContext.Items, AyaType.QuoteItemUnit) || biz.UserIsRestrictedType) + return StatusCode(403, new ApiNotAuthorizedResponse()); + if (!await biz.UnitDeleteAsync(QuoteItemUnitId)) + return BadRequest(new ApiErrorResponse(biz.Errors)); + return NoContent(); + } + + #endregion QuoteItemUnit diff --git a/server/AyaNova/Controllers/SearchController.cs b/server/AyaNova/Controllers/SearchController.cs index bbb2d88c..31b3dd1f 100644 --- a/server/AyaNova/Controllers/SearchController.cs +++ b/server/AyaNova/Controllers/SearchController.cs @@ -141,8 +141,24 @@ namespace AyaNova.Api.Controllers case AyaType.WorkOrderItemTravel: case AyaType.WorkOrderItemOutsideService: case AyaType.WorkOrderItemUnit: - AyaTypeId TypeId = new AyaTypeId(AyaType.WorkOrder, (await WorkOrderBiz.GetWorkOrderIdFromRelativeAsync(ayaType, id, ct)).WorkOrderId); - return Ok(ApiOkResponse.Response(new { AyaType = TypeId.ATypeAsInt, Id = TypeId.ObjectId })); + { + AyaTypeId TypeId = new AyaTypeId(AyaType.WorkOrder, (await WorkOrderBiz.GetWorkOrderIdFromRelativeAsync(ayaType, id, ct)).ParentId); + return Ok(ApiOkResponse.Response(new { AyaType = TypeId.ATypeAsInt, Id = TypeId.ObjectId })); + } + case AyaType.QuoteItem: + case AyaType.QuoteItemExpense: + case AyaType.QuoteItemLabor: + case AyaType.QuoteItemLoan: + case AyaType.QuoteItemPart: + case AyaType.QuoteItemScheduledUser: + case AyaType.QuoteItemTask: + case AyaType.QuoteItemTravel: + case AyaType.QuoteItemOutsideService: + case AyaType.QuoteItemUnit: + { + AyaTypeId TypeId = new AyaTypeId(AyaType.Quote, (await QuoteBiz.GetQuoteIdFromRelativeAsync(ayaType, id, ct)).ParentId); + return Ok(ApiOkResponse.Response(new { AyaType = TypeId.ATypeAsInt, Id = TypeId.ObjectId })); + } default: return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, "Only types with ancestors are valid")); diff --git a/server/AyaNova/Controllers/WorkOrderController.cs b/server/AyaNova/Controllers/WorkOrderController.cs index 1a04a071..8b86ef87 100644 --- a/server/AyaNova/Controllers/WorkOrderController.cs +++ b/server/AyaNova/Controllers/WorkOrderController.cs @@ -38,11 +38,7 @@ namespace AyaNova.Api.Controllers serverState = apiServerState; } - //todo: finish this off, it's missing some shit, and also check it's modernized - //will also likely need a seperate fetch route for just the header and just an item - //prefer named routes for each rather than some kind of parameter for existing routes, i.e. get{id} for whole graph and get headeronly/{id} - - //STATES OUTSIDE SERVICE + /* ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ diff --git a/server/AyaNova/DataList/QuoteDataList.cs b/server/AyaNova/DataList/QuoteDataList.cs new file mode 100644 index 00000000..067c9d98 --- /dev/null +++ b/server/AyaNova/DataList/QuoteDataList.cs @@ -0,0 +1,359 @@ +using System.Collections.Generic; +using System.Linq; +using AyaNova.Biz; +using AyaNova.Models; + +namespace AyaNova.DataList +{ + internal class QuoteDataList : DataListProcessingBase, IDataListInternalCriteria + { + public QuoteDataList() + { + DefaultListAType = AyaType.Quote; + SQLFrom = "from aquote " + + "left join aquotestatus on (aquote.laststatusid = aquotestatus.id) " + + "left join acustomer on (aquote.customerid=acustomer.id) " + + "left join aheadoffice on (acustomer.headofficeid=aheadoffice.id) " + + "left join aproject on (aquote.projectid=aproject.id) " + + "left join acontract on (aquote.contractid=acontract.id)"; + var RoleSet = BizRoles.GetRoleSet(DefaultListAType); + AllowedRoles = RoleSet.ReadFullRecord | RoleSet.Change; + DefaultColumns = new List() { "QuoteSerialNumber", "Customer", "QuoteStatus", "Project" }; + DefaultSortBy = new Dictionary() { { "QuoteSerialNumber", "-" } }; + FieldDefinitions = new List(); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "QuoteSerialNumber", + FieldKey = "QuoteSerialNumber", + AType = (int)AyaType.Quote, + UiFieldDataType = (int)UiFieldDataType.Integer, + SqlIdColumnName = "aquote.id", + SqlValueColumnName = "aquote.serial", + IsRowId = true + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + FieldKey = "Customer", + TKey = "Customer", + UiFieldDataType = (int)UiFieldDataType.Text, + AType = (int)AyaType.Customer, + SqlIdColumnName = "acustomer.id", + SqlValueColumnName = "acustomer.name" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "HeadOffice", + FieldKey = "quoteheadoffice", + UiFieldDataType = (int)UiFieldDataType.Text, + AType = (int)AyaType.HeadOffice, + SqlIdColumnName = "aheadoffice.id", + SqlValueColumnName = "aheadoffice.name" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "QuoteStatus", + FieldKey = "QuoteStatus", + UiFieldDataType = (int)UiFieldDataType.Text, + AType = (int)AyaType.QuoteStatus, + SqlIdColumnName = "aquote.laststatusid", + SqlColorColumnName = "aquotestatus.color", + SqlValueColumnName = "aquotestatus.name" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "QuoteSummary", + FieldKey = "quotenotes", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.notes" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "Tags", + FieldKey = "quotetags", + UiFieldDataType = (int)UiFieldDataType.Tags, + SqlValueColumnName = "aquote.tags" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + FieldKey = "Project", + TKey = "Project", + UiFieldDataType = (int)UiFieldDataType.Text, + AType = (int)AyaType.Project, + SqlIdColumnName = "aproject.id", + SqlValueColumnName = "aproject.name" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "Contract", + FieldKey = "Contract", + UiFieldDataType = (int)UiFieldDataType.Text, + AType = (int)AyaType.Contract, + SqlIdColumnName = "acontract.id", + SqlValueColumnName = "acontract.name" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "QuoteInternalReferenceNumber", + FieldKey = "QuoteInternalReferenceNumber", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.internalreferencenumber" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "QuoteCustomerReferenceNumber", + FieldKey = "QuoteCustomerReferenceNumber", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.customerreferencenumber" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "QuoteCustomerContactName", + FieldKey = "QuoteCustomerContactName", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.customercontactname" + }); + + // FieldDefinitions.Add(new DataListFieldDefinition + // { + // TKey = "QuoteServiceDate", + // FieldKey = "QuoteServiceDate", + // UiFieldDataType = (int)UiFieldDataType.DateTime, + // SqlValueColumnName = "aquote.servicedate" + // }); + + // FieldDefinitions.Add(new DataListFieldDefinition + // { + // TKey = "QuoteCloseByDate", + // FieldKey = "QuoteCloseByDate", + // UiFieldDataType = (int)UiFieldDataType.DateTime, + // SqlValueColumnName = "aquote.completebydate" + // }); + + // FieldDefinitions.Add(new DataListFieldDefinition + // { + // TKey = "QuoteInvoiceNumber", + // FieldKey = "QuoteInvoiceNumber", + // UiFieldDataType = (int)UiFieldDataType.Text, + // SqlValueColumnName = "aquote.invoicenumber" + // }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "QuoteOnsite", + FieldKey = "QuoteOnsite", + UiFieldDataType = (int)UiFieldDataType.Bool, + SqlValueColumnName = "aquote.onsite" + }); + + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressPostalDeliveryAddress", + FieldKey = "quotepostaddress", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.postaddress" + }); + + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressPostalCity", + FieldKey = "quotepostcity", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.postcity" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressPostalStateProv", + FieldKey = "quotepostregion", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.postregion" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressPostalCountry", + FieldKey = "quotepostcountry", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.postcountry" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressPostalPostal", + FieldKey = "quotepostcode", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.postcode" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressDeliveryAddress", + FieldKey = "quoteaddress", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.address" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressCity", + FieldKey = "quotecity", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.city" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressStateProv", + FieldKey = "quoteregion", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.region" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressCountry", + FieldKey = "quotecountry", + UiFieldDataType = (int)UiFieldDataType.Text, + SqlValueColumnName = "aquote.country" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressLatitude", + FieldKey = "quotelatitude", + UiFieldDataType = (int)UiFieldDataType.Decimal, + SqlValueColumnName = "aquote.latitude" + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + TKey = "AddressLongitude", + FieldKey = "quotelongitude", + UiFieldDataType = (int)UiFieldDataType.Decimal, + SqlValueColumnName = "aquote.longitude" + }); + + + // FieldDefinitions.Add(new DataListFieldDefinition + // { + // TKey = "QuoteCloseByDate", + // FieldKey = "QuoteCloseByDate", + // UiFieldDataType = (int)UiFieldDataType.DateTime, + // SqlValueColumnName = "aquote.closebydate" + // }); + + + // FieldDefinitions.Add(new DataListFieldDefinition + // { + // TKey = "QuoteAge", + // FieldKey = "QuoteAge", + // UiFieldDataType = (int)UiFieldDataType.TimeSpan, + // SqlValueColumnName = "expwoage" + // }); + + // FieldDefinitions.Add(new DataListFieldDefinition + // { + // TKey = "TimeToCompletion", + // FieldKey = "TimeToCompletion", + // UiFieldDataType = (int)UiFieldDataType.TimeSpan, + // SqlValueColumnName = "durationtocompleted" + // }); + + + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom1", FieldKey = "quotecustom1", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom2", FieldKey = "quotecustom2", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom3", FieldKey = "quotecustom3", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom4", FieldKey = "quotecustom4", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom5", FieldKey = "quotecustom5", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom6", FieldKey = "quotecustom6", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom7", FieldKey = "quotecustom7", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom8", FieldKey = "quotecustom8", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom9", FieldKey = "quotecustom9", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom10", FieldKey = "quotecustom10", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom11", FieldKey = "quotecustom11", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom12", FieldKey = "quotecustom12", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom13", FieldKey = "quotecustom13", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom14", FieldKey = "quotecustom14", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom15", FieldKey = "quotecustom15", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + FieldDefinitions.Add(new DataListFieldDefinition { TKey = "QuoteCustom16", FieldKey = "quotecustom16", IsCustomField = true, IsFilterable = false, IsSortable = false, SqlValueColumnName = "aquote.customfields" }); + + + //META COLUMNS + + FieldDefinitions.Add(new DataListFieldDefinition + { + FieldKey = "metacustomer", + UiFieldDataType = (int)UiFieldDataType.InternalId, + SqlIdColumnName = "acustomer.id", + SqlValueColumnName = "acustomer.id", + IsMeta = true + }); + + FieldDefinitions.Add(new DataListFieldDefinition + { + FieldKey = "metaproject", + UiFieldDataType = (int)UiFieldDataType.InternalId, + SqlIdColumnName = "aproject.id", + SqlValueColumnName = "aproject.id", + IsMeta = true + }); + + + + } + + + public List DataListInternalCriteria(long currentUserId, AuthorizationRoles userRoles, string clientCriteria) + { + List ret = new List(); + + //ClientCriteria format for this list is "OBJECTID,AYATYPE" + var crit = (clientCriteria ?? "").Split(',').Select(z => z.Trim()).ToArray(); + if (crit.Length > 1) + { + //will be filtered from different types, show all quotes from Customer, Project and nothing else at this time (others but for sub lists like quoteitemunits etc) + int nType = 0; + if (!int.TryParse(crit[1], out nType)) return ret; + AyaType forType = (AyaType)nType; + if (forType != AyaType.Customer && forType != AyaType.Project) return ret;//only supports customer and project for now + + long lId = 0; + if (!long.TryParse(crit[0], out lId)) return ret; + if (lId == 0) return ret; + + //Have valid type, have an id, so filter away + switch (forType) + { + case AyaType.Customer: + { + DataListFilterOption FilterOption = new DataListFilterOption() { Column = "metacustomer" }; + FilterOption.Items.Add(new DataListColumnFilter() { value = crit[0], op = DataListFilterComparisonOperator.Equality }); + ret.Add(FilterOption); + } + break; + case AyaType.Project: + { + DataListFilterOption FilterOption = new DataListFilterOption() { Column = "metaproject" }; + FilterOption.Items.Add(new DataListColumnFilter() { value = crit[0], op = DataListFilterComparisonOperator.Equality }); + ret.Add(FilterOption); + } + break; + } + } + return ret; + } + }//eoc +}//eons \ No newline at end of file diff --git a/server/AyaNova/biz/BizObjectFactory.cs b/server/AyaNova/biz/BizObjectFactory.cs index d38dc6fc..21a5c9cb 100644 --- a/server/AyaNova/biz/BizObjectFactory.cs +++ b/server/AyaNova/biz/BizObjectFactory.cs @@ -64,15 +64,14 @@ namespace AyaNova.Biz case AyaType.PM: return new PMBiz(ct, userId, translationId, roles); - + case AyaType.Project: return new ProjectBiz(ct, userId, translationId, roles); case AyaType.PurchaseOrder: return new PurchaseOrderBiz(ct, userId, translationId, roles); - case AyaType.Quote: - return new QuoteBiz(ct, userId, translationId, roles); - + + case AyaType.Unit: return new UnitBiz(ct, userId, translationId, roles); case AyaType.UnitModel: @@ -93,8 +92,24 @@ namespace AyaNova.Biz case AyaType.WorkOrderItemUnit: case AyaType.WorkOrderItemOutsideService: return new WorkOrderBiz(ct, userId, translationId, roles, UserType.NotService);//default to not service for now arbitrarily on the principle of least access + //--- + + + //--- Quote + case AyaType.Quote: + case AyaType.QuoteItem: + case AyaType.QuoteItemExpense: + case AyaType.QuoteItemLabor: + case AyaType.QuoteItemLoan: + case AyaType.QuoteItemPart: + case AyaType.QuoteItemScheduledUser: + case AyaType.QuoteItemTask: + case AyaType.QuoteItemTravel: + case AyaType.QuoteItemUnit: + case AyaType.QuoteItemOutsideService: + return new QuoteBiz(ct, userId, translationId, roles, UserType.NotService);//default to not service for now arbitrarily on the principle of least access //--- - + case AyaType.Reminder: return new ReminderBiz(ct, userId, translationId, roles); case AyaType.Review: diff --git a/server/AyaNova/biz/BizRoles.cs b/server/AyaNova/biz/BizRoles.cs index 48cb55a9..75d444a3 100644 --- a/server/AyaNova/biz/BizRoles.cs +++ b/server/AyaNova/biz/BizRoles.cs @@ -269,7 +269,7 @@ namespace AyaNova.Biz Select = AuthorizationRoles.All }); - + //////////////////////////////////////////////////////////// //Project @@ -382,7 +382,7 @@ namespace AyaNova.Biz Select = AuthorizationRoles.All }); - + //////////////////////////////////////////////////////////// //Unit // @@ -561,6 +561,31 @@ namespace AyaNova.Biz + //////////////////////////////////////////////////////////// + //Quote + // + + var quoteBizRoleSet = new BizRoleSet() + { + Change = AuthorizationRoles.BizAdmin | AuthorizationRoles.Service | AuthorizationRoles.Sales | AuthorizationRoles.Accounting, + ReadFullRecord = AuthorizationRoles.BizAdminRestricted | AuthorizationRoles.ServiceRestricted | AuthorizationRoles.SalesRestricted, + Select = AuthorizationRoles.All + }; + roles.Add(AyaType.Quote, quoteBizRoleSet); + roles.Add(AyaType.QuoteItem, quoteBizRoleSet); + roles.Add(AyaType.QuoteItemExpense,quoteBizRoleSet); + roles.Add(AyaType.QuoteItemLabor,quoteBizRoleSet); + roles.Add(AyaType.QuoteItemLoan, quoteBizRoleSet); + roles.Add(AyaType.QuoteItemPart,quoteBizRoleSet); + roles.Add(AyaType.QuoteItemScheduledUser, quoteBizRoleSet); + roles.Add(AyaType.QuoteItemTask, quoteBizRoleSet); + roles.Add(AyaType.QuoteItemTravel, quoteBizRoleSet); + roles.Add(AyaType.QuoteItemUnit, quoteBizRoleSet); + roles.Add(AyaType.QuoteItemOutsideService,quoteBizRoleSet); + //--- + + + //////////////////////////////////////////////////////////// //GLOBAL BIZ SETTINGS // diff --git a/server/AyaNova/biz/QuoteBiz.cs b/server/AyaNova/biz/QuoteBiz.cs index de9af252..980f40ec 100644 --- a/server/AyaNova/biz/QuoteBiz.cs +++ b/server/AyaNova/biz/QuoteBiz.cs @@ -1,213 +1,605 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; - +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore.Storage; using AyaNova.Util; using AyaNova.Api.ControllerHelpers; using AyaNova.Models; +using System.Linq; +using System; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; namespace AyaNova.Biz { - internal class QuoteBiz : BizObject, ISearchAbleObject - { - //Feature specific roles - internal static AuthorizationRoles RolesAllowedToChangeSerial = AuthorizationRoles.BizAdmin | AuthorizationRoles.Service | AuthorizationRoles.Accounting; - internal QuoteBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles) + internal class QuoteBiz : BizObject, IJobObject, ISearchAbleObject, IReportAbleObject, IExportAbleObject + { + internal QuoteBiz(AyContext dbcontext, long currentUserId, long userTranslationId, AuthorizationRoles UserRoles, UserType currentUserType) { ct = dbcontext; UserId = currentUserId; UserTranslationId = userTranslationId; CurrentUserRoles = UserRoles; BizType = AyaType.Quote; + CurrentUserType = currentUserType; + + //Sub-role rights flags + UserIsTechRestricted = CurrentUserRoles.HasFlag(AuthorizationRoles.TechRestricted); + UserIsSubContractorFull = CurrentUserType == UserType.ServiceContractor && CurrentUserRoles.HasFlag(AuthorizationRoles.SubContractor); + UserIsSubContractorRestricted = CurrentUserType == UserType.ServiceContractor && CurrentUserRoles.HasFlag(AuthorizationRoles.SubContractorRestricted); + UserIsRestrictedType = UserIsTechRestricted || UserIsSubContractorFull || UserIsSubContractorRestricted; + UserCanViewPartCosts = CurrentUserRoles.HasFlag(AuthorizationRoles.InventoryRestricted) + || CurrentUserRoles.HasFlag(AuthorizationRoles.Inventory) + || CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin) + || CurrentUserRoles.HasFlag(AuthorizationRoles.Accounting); + UserCanViewLaborOrTravelRateCosts = CurrentUserRoles.HasFlag(AuthorizationRoles.Service) + || CurrentUserRoles.HasFlag(AuthorizationRoles.ServiceRestricted) + || CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin) + || CurrentUserRoles.HasFlag(AuthorizationRoles.Accounting); + UserCanViewLoanerCosts = CurrentUserRoles.HasFlag(AuthorizationRoles.Service) + || CurrentUserRoles.HasFlag(AuthorizationRoles.ServiceRestricted) + || CurrentUserRoles.HasFlag(AuthorizationRoles.BizAdmin) + || CurrentUserRoles.HasFlag(AuthorizationRoles.Accounting); } internal static QuoteBiz GetBiz(AyContext ct, Microsoft.AspNetCore.Http.HttpContext httpContext = null) { - if (httpContext != null) - return new QuoteBiz(ct, UserIdFromContext.Id(httpContext.Items), UserTranslationIdFromContext.Id(httpContext.Items), UserRolesFromContext.Roles(httpContext.Items)); - else//when called internally for internal ops there will be no context so need to set default values for that - return new QuoteBiz(ct, 1, ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, AuthorizationRoles.BizAdmin); + return new QuoteBiz(ct, + UserIdFromContext.Id(httpContext.Items), + UserTranslationIdFromContext.Id(httpContext.Items), + UserRolesFromContext.Roles(httpContext.Items), + UserTypeFromContext.Type(httpContext.Items)); + else + return new QuoteBiz(ct, + 1, + ServerBootConfig.AYANOVA_DEFAULT_TRANSLATION_ID, + AuthorizationRoles.BizAdmin, + UserType.NotService);//picked not service arbitrarily, probably a non-factor } + /* + ██████╗ ██╗ ██╗ ██████╗ ████████╗███████╗ + ██╔═══██╗██║ ██║██╔═══██╗╚══██╔══╝██╔════╝ + ██║ ██║██║ ██║██║ ██║ ██║ █████╗ + ██║▄▄ ██║██║ ██║██║ ██║ ██║ ██╔══╝ + ╚██████╔╝╚██████╔╝╚██████╔╝ ██║ ███████╗ + ╚══▀▀═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ + */ + + #region Quote level + + //////////////////////////////////////////////////////////////////////////////////////////////// + // SUBRIGHTS / RESTRICTIONS FOR WORK ORDER + // + + //Note: these restrictions and rights are in addition to the basic fundamental role access rights (layer 1) + //and are considered after role rights have already been consulted first (usually at the controller level) + + internal UserType CurrentUserType { get; set; } + internal bool UserIsRestrictedType { get; set; } + internal bool UserIsTechRestricted { get; set; } + internal bool UserIsSubContractorFull { get; set; } + internal bool UserIsSubContractorRestricted { get; set; } + internal bool UserCanViewPartCosts { get; set; } + internal bool UserCanViewLaborOrTravelRateCosts { get; set; } + internal bool UserCanViewLoanerCosts { get; set; } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// //EXISTS - internal async Task ExistsAsync(long id) + internal async Task QuoteExistsAsync(long id) { return await ct.Quote.AnyAsync(z => z.Id == id); } - //################################################################################################################################################### - //################################################################################################################################################### - // WARNING! THIS OBJECT IS AN INITIAL TEST VERSION NOT UP TO CURRENT STANDARDS, SEE WORKORDERBIZ FOR HOW THIS SHOULD BE CODED - //################################################################################################################################################### - //################################################################################################################################################### - - - //////////////////////////////////////////////////////////////////////////////////////////////// - /// GET - /// - /// - - internal async Task GetAsync(long fetchId, bool logTheGetEvent = true) - { - //This is simple so nothing more here, but often will be copying to a different output object or some other ops - var ret = await ct.Quote.SingleOrDefaultAsync(z => z.Id == fetchId); - if (logTheGetEvent && ret != null) - { - //Log - await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, fetchId, BizType, AyaEvent.Retrieved), ct); - } - return ret; - } - - - //################################################################################################################################################### - //################################################################################################################################################### - // WARNING! THIS OBJECT IS AN INITIAL TEST VERSION NOT UP TO CURRENT STANDARDS, SEE WORKORDERBIZ FOR HOW THIS SHOULD BE CODED - //################################################################################################################################################### - //################################################################################################################################################### //////////////////////////////////////////////////////////////////////////////////////////////// //CREATE - - //Called from route and also seeder - internal async Task CreateAsync(long? workorderTemplateId, long? customerId, uint? serial) + // + internal async Task QuoteCreateAsync(Quote newObject, bool populateViz = true) { - //Create and save to db a new workorder and return it - //NOTE: Serial can be specified or edited after the fact in a limited way by full role specfic only!! (service manager, bizadminfull, accounting maybe) - - if (serial != null && !Authorized.HasAnyRole(CurrentUserRoles, RolesAllowedToChangeSerial)) + using (var transaction = await ct.Database.BeginTransactionAsync()) { - AddError(ApiErrorCode.NOT_AUTHORIZED, "Serial"); + await QuoteValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await QuoteBizActionsAsync(AyaEvent.Created, newObject, null, null); + newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.Quote.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, BizType, AyaEvent.Created), ct); + await QuoteSearchIndexAsync(newObject, true); + await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + + //# NOTE: only internal code can post an entire quote graph, no external user can as controller will reject right up front + //however, internally seeder will post entire workorders + if (newObject.Items.Count > 0) + { + await GetCurrentContractFromContractIdAsync(newObject.ContractId); + + + + //GRANDCHILD BIZ ACTIONS + foreach (QuoteItem wi in newObject.Items) + { + foreach (QuoteItemPart wip in wi.Parts) + await PartBizActionsAsync(AyaEvent.Created, wip, null, null); + foreach (QuoteItemLoan wil in wi.Loans) + await LoanBizActionsAsync(AyaEvent.Created, wil, null, null); + + } + await ct.SaveChangesAsync(); + + + + //NOTE: not running individual notification here for children, seeder won't require it and that's all that posts an entire wo currently + } + await transaction.CommitAsync(); + if (populateViz) + await QuotePopulateVizFields(newObject, true, false); + + await QuoteHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + return newObject; + } + } + } + + + + + //quote needs to be fetched internally from several places for rule checking etc + //this just gets it raw and lets others process + private async Task QuoteGetFullAsync(long id) + { + //https://docs.microsoft.com/en-us/ef/core/querying/related-data + //docs say this will not query twice but will recognize the duplicate woitem bit which is required for multiple grandchild collections + return await ct.Quote.AsSplitQuery().AsNoTracking() + .Include(s => s.States) + .Include(w => w.Items.OrderBy(item => item.Sequence)) + .ThenInclude(wi => wi.Expenses) + .Include(w => w.Items) + .ThenInclude(wi => wi.Labors) + .Include(w => w.Items) + .ThenInclude(wi => wi.Loans) + .Include(w => w.Items) + .ThenInclude(wi => wi.Parts) + .Include(w => w.Items) + .ThenInclude(wi => wi.ScheduledUsers) + .Include(w => w.Items) + .ThenInclude(wi => wi.Tasks.OrderBy(t => t.Sequence)) + .Include(w => w.Items) + .ThenInclude(wi => wi.Travels) + .Include(w => w.Items) + .ThenInclude(wi => wi.Units) + .Include(w => w.Items) + .ThenInclude(wi => wi.OutsideServices) + .SingleOrDefaultAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task QuoteGetAsync(long id, bool populateDisplayFields, bool logTheGetEvent = true, bool populateForReporting = false) + { + + var ret = await QuoteGetFullAsync(id); + + if (ret != null) + { + var stat = await GetCurrentQuoteStatusFromRelatedAsync(BizType, ret.Id); + ret.IsLockedAtServer = stat.Locked; + + var userIsTechRestricted = UserIsTechRestricted; + var userIsSubContractorFull = UserIsSubContractorFull; + var userIsSubContractorRestricted = UserIsSubContractorRestricted; + var userIsRestricted = (userIsTechRestricted || userIsSubContractorFull || userIsSubContractorRestricted); + + + if (userIsRestricted) + { + //Restricted users can only work with quote items they are scheduled on + + List removeItems = new List(); + //gather list of items to remove by checking if they are scheduled on them or not + foreach (QuoteItem wi in ret.Items) + { + var userIsSelfScheduledOnThisItem = false; + foreach (QuoteItemScheduledUser su in wi.ScheduledUsers) + { + if (su.UserId == UserId) + { + userIsSelfScheduledOnThisItem = true; + break; + } + } + if (!userIsSelfScheduledOnThisItem) removeItems.Add(wi); + } + foreach (var removeitem in removeItems) + { + ret.Items.Remove(removeitem); + ret.IsCompleteRecord = false; + } + + //Restricted users may have further restrictions + foreach (QuoteItem wi in ret.Items) + { + //all restricted types + wi.ScheduledUsers.RemoveAll(x => x.UserId != UserId); + wi.Labors.RemoveAll(x => x.UserId != UserId); + wi.Travels.RemoveAll(x => x.UserId != UserId); + + if (userIsTechRestricted) + { + wi.Expenses.RemoveAll(x => x.UserId != UserId); + } + + if (userIsSubContractorFull) + { + wi.Expenses.RemoveAll(x => true); + wi.OutsideServices.RemoveAll(x => true); + } + + if (userIsSubContractorRestricted) + { + wi.Units.RemoveAll(x => true); + wi.Parts.RemoveAll(x => true); + wi.Expenses.RemoveAll(x => true); + wi.Loans.RemoveAll(x => true); + wi.OutsideServices.RemoveAll(x => true); + } + + //tasks are allowed to be viewed and update the task completion types + } + } + + if (populateDisplayFields) + await QuotePopulateVizFields(ret, false, populateForReporting); + + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, BizType, AyaEvent.Retrieved), ct); + } + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task QuotePutAsync(Quote putObject) + { + //## PUT HEADER ONLY, NO ALLOWANCE FOR PUT OF ENTIRE WORKORDER + + //Note: this is intentionally not using the getasync because + //doing so would invoke the children which would then get deleted on save since putobject has no children + Quote dbObject = await ct.Quote.AsNoTracking().FirstOrDefaultAsync(z => z.Id == putObject.Id); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); return null; } - // await ValidateAsync(inObj, null); - // if (HasErrors) - // return null; - // else - // { - //do stuff with Quote - Quote o = new Quote(); + putObject.Tags = TagBiz.NormalizeTags(putObject.Tags); + putObject.CustomFields = JsonUtil.CompactJson(putObject.CustomFields); + await QuoteValidateAsync(putObject, dbObject); + if (HasErrors) + return null; + await QuoteBizActionsAsync(AyaEvent.Modified, putObject, dbObject, null); - //TODO: template - //TODO: CUSTOMER ID + long? newContractId = null; + if (putObject.ContractId != dbObject.ContractId)//manual change of contract + { + newContractId = putObject.ContractId; + await GetCurrentContractFromContractIdAsync(newContractId); + } - //Save to db - await ct.Quote.AddAsync(o); - await ct.SaveChangesAsync(); - //Handle child and associated items: - await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, o.Id, BizType, AyaEvent.Created), ct); - await SearchIndexAsync(o, true); - // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, o.Tags, null); - - return o; - //} + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await QuoteExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified), ct); + await QuoteSearchIndexAsync(putObject, false); + await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); + await QuotePopulateVizFields(putObject, true, false);//doing this here ahead of notification because notification may require the viz field lookup anyway and afaict no harm in it + await QuoteHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + return putObject; } + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task QuoteDeleteAsync(long id) + { + using (var transaction = await ct.Database.BeginTransactionAsync()) + { + try + { + Quote dbObject = await ct.Quote.AsNoTracking().Where(z => z.Id == id).FirstOrDefaultAsync();// QuoteGetAsync(id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND); + return false; + } + QuoteValidateCanDelete(dbObject); + if (HasErrors) + return false; + + //States collection + if (!await StateDeleteAsync(id, transaction)) + return false; + + //collect the child id's to delete + var ItemIds = await ct.QuoteItem.AsNoTracking().Where(z => z.QuoteId == id).Select(z => z.Id).ToListAsync(); + + //Delete children + foreach (long ItemId in ItemIds) + if (!await ItemDeleteAsync(ItemId, transaction)) + return false; + + ct.Quote.Remove(dbObject); + await ct.SaveChangesAsync(); + + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, dbObject.Serial.ToString(), ct); + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); + await transaction.CommitAsync(); + await QuoteHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //NOTE: no need to rollback the transaction, it will auto-rollback if not committed and it is disposed when it goes out of scope either way + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + + } + return true; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //BIZ ACTIONS + // + // + private async Task QuoteBizActionsAsync(AyaEvent ayaEvent, Quote newObj, Quote oldObj, IDbContextTransaction transaction) + { + //automatic actions on record change, called AFTER validation and BEFORE save + //so changes here will be saved by caller + + //currently no processing required except for created or modified at this time + if (ayaEvent != AyaEvent.Created && ayaEvent != AyaEvent.Modified) + return; + + + //CREATED OR MODIFIED + if (ayaEvent == AyaEvent.Created || ayaEvent == AyaEvent.Modified) + { + + } + + + //CREATION ACTIONS + if (ayaEvent == AyaEvent.Created) + { + await AutoSetContractAsync(newObj); + + await AutoSetAddressAsync(newObj); + } + + //MODIFIED ACTIONS + if (ayaEvent == AyaEvent.Modified) + { + //if customer changed then contractId must be re-checked + if (newObj.CustomerId != oldObj.CustomerId) + { + await AutoSetContractAsync(newObj); + await AutoSetAddressAsync(newObj); + } + + + } + } + + private async Task AutoSetAddressAsync(Quote newObj) + { + if (newObj.CustomerId == 0) + return; + + var cust = await ct.Customer.AsNoTracking().Where(z => z.Id == newObj.CustomerId).FirstOrDefaultAsync(); + if (cust == null) + return; + + newObj.PostAddress = cust.PostAddress; + newObj.PostCity = cust.PostCity; + newObj.PostRegion = cust.PostRegion; + newObj.PostCountry = cust.PostCountry; + newObj.PostCode = cust.PostCode; + + newObj.Address = cust.Address; + newObj.City = cust.City; + newObj.Region = cust.Region; + newObj.Country = cust.Country; + newObj.Latitude = cust.Latitude; + newObj.Longitude = cust.Longitude; + + if (cust.BillHeadOffice == true && cust.HeadOfficeId != null) + { + var head = await ct.HeadOffice.AsNoTracking().Where(z => z.Id == cust.HeadOfficeId).FirstOrDefaultAsync(); + if (head == null) + return; + newObj.PostAddress = head.PostAddress; + newObj.PostCity = head.PostCity; + newObj.PostRegion = head.PostRegion; + newObj.PostCountry = head.PostCountry; + newObj.PostCode = head.PostCode; + + } + } + + private async Task AutoSetContractAsync(Quote newObj) + { + //first reset contract fetched flag so a fresh copy is taken + //in case it was set already by other operations + mFetchedContractAlready = false; + + //CONTRACT AUTO SET + //failsafe + newObj.ContractId = null; + + if (newObj.CustomerId != 0) + { + //precedence: unit->customer->headoffice + var cust = await ct.Customer.AsNoTracking().Where(z => z.Id == newObj.CustomerId).Select(z => new { headofficeId = z.HeadOfficeId, contractId = z.ContractId, contractExpires = z.ContractExpires }).FirstOrDefaultAsync(); + + //first set it to the customer one if available in case the ho one has expired then set the ho if applicable + if (cust.contractId != null && cust.contractExpires > DateTime.UtcNow) + newObj.ContractId = cust.contractId; + else if (cust.headofficeId != null) + { + var head = await ct.HeadOffice.AsNoTracking().Where(z => z.Id == cust.headofficeId).Select(z => new { contractId = z.ContractId, contractExpires = z.ContractExpires }).FirstOrDefaultAsync(); + if (head.contractId != null && head.contractExpires > DateTime.UtcNow) + newObj.ContractId = head.contractId; + } + } + } // //////////////////////////////////////////////////////////////////////////////////////////////// - // //DUPLICATE - // // - - // internal async Task DuplicateAsync(Quote dbObject) + // //CONTRACT UPDATE + // // + // internal async Task ChangeContract(long workOrderId, long? newContractId) // { - // await Task.CompletedTask; - // throw new System.NotImplementedException("STUB: WORKORDER DUPLICATE"); - // // Quote outObj = new Quote(); - // // CopyObject.Copy(dbObject, outObj, "Wiki"); - // // // outObj.Name = Util.StringUtil.NameUniquify(outObj.Name, 255); - // // //generate unique name - // // string newUniqueName = string.Empty; - // // bool NotUnique = true; - // // long l = 1; - // // do - // // { - // // newUniqueName = Util.StringUtil.UniqueNameBuilder(dbObject.Name, l++, 255); - // // NotUnique = await ct.Quote.AnyAsync(z => z.Name == newUniqueName); - // // } while (NotUnique); - - // // outObj.Name = newUniqueName; - - - // // outObj.Id = 0; - // // outObj.Concurrency = 0; - - // // await ct.Quote.AddAsync(outObj); - // // await ct.SaveChangesAsync(); - - // // //Handle child and associated items: - // // await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, outObj.Id, BizType, AyaEvent.Created), ct); - // // await SearchIndexAsync(outObj, true); - // // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, outObj.Tags, null); - // // return outObj; + // //this is called by UI via contract change route for contract change only and expects wo back to update client ui + // var w = await ct.Quote.FirstOrDefaultAsync(z => z.Id == workOrderId); + // if (w == null) + // { + // AddError(ApiErrorCode.NOT_FOUND, "id"); + // return null; + // } + // if (newContractId != null && !await ct.Contract.AnyAsync(z => z.Id == newContractId)) + // { + // AddError(ApiErrorCode.NOT_FOUND, "generalerror", $"Contract with id {newContractId} not found"); + // return null; + // } + // w.ContractId = newContractId; + // await ct.SaveChangesAsync(); + // await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, workOrderId, BizType, AyaEvent.Modified), ct); + // await GetCurrentContractFromContractIdAsync(newContractId); + // var updatedQuote = await ProcessChangeOfContractAsync(workOrderId); + // await QuotePopulateVizFields(updatedQuote, false); + // return updatedQuote;//return entire quote // } - - //################################################################################################################################################### - //################################################################################################################################################### - // WARNING! THIS OBJECT IS AN INITIAL TEST VERSION NOT UP TO CURRENT STANDARDS, SEE PARTASSEMBLYBIZ / some of WORKORDERBIZ FOR HOW THIS SHOULD BE CODED - //################################################################################################################################################### - //################################################################################################################################################### + //////////////////////////////////////////////////////////////////////////////////////////////// - //UPDATE - // - - //put - internal async Task PutAsync(Quote dbObject, Quote putObj) + //GET WORKORDER ID FROM DESCENDANT TYPE AND ID + // + internal static async Task GetQuoteIdFromRelativeAsync(AyaType ayaType, long id, AyContext ct) { - - // make a snapshot of the original for validation but update the original to preserve workflow - Quote SnapshotOfOriginalDBObj = new Quote(); - CopyObject.Copy(dbObject, SnapshotOfOriginalDBObj); - - //Replace the db object with the PUT object - CopyObject.Copy(putObj, dbObject, "Id,Serial"); - - //if user has rights then change it, otherwise just ignore it and do the rest - if (SnapshotOfOriginalDBObj.Serial != putObj.Serial && Authorized.HasAnyRole(CurrentUserRoles, RolesAllowedToChangeSerial)) + ParentAndChildItemId w = new ParentAndChildItemId(); + long itemid = 0; + switch (ayaType) { - dbObject.Serial = putObj.Serial; + case AyaType.Quote: + w.ParentId = id; + w.ChildItemId = 0; + return w; + case AyaType.QuoteItem: + itemid = id; + break; + case AyaType.QuoteItemExpense: + itemid = await ct.QuoteItemExpense.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteItemLabor: + itemid = await ct.QuoteItemLabor.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteItemLoan: + itemid = await ct.QuoteItemLoan.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteItemPart: + itemid = await ct.QuoteItemPart.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteItemScheduledUser: + itemid = await ct.QuoteItemScheduledUser.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteItemTask: + itemid = await ct.QuoteItemTask.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteItemTravel: + itemid = await ct.QuoteItemTravel.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteItemOutsideService: + itemid = await ct.QuoteItemOutsideService.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + case AyaType.QuoteStatus: + w.ParentId = await ct.QuoteState.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteId).SingleOrDefaultAsync(); + w.ChildItemId = 0; + return w; + case AyaType.QuoteItemUnit: + itemid = await ct.QuoteItemUnit.AsNoTracking().Where(z => z.Id == id).Select(z => z.QuoteItemId).SingleOrDefaultAsync(); + break; + default: + throw new System.NotSupportedException($"QuoteBiz::GetQuoteIdFromRelativeAsync -> AyaType {ayaType.ToString()} is not supported"); } - dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); - dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); - - //Set "original" value of concurrency token to input token - //this will allow EF to check it out - ct.Entry(dbObject).OriginalValues["Concurrency"] = putObj.Concurrency; + w.ParentId = await ct.QuoteItem.AsNoTracking() + .Where(z => z.Id == itemid) + .Select(z => z.QuoteId) + .SingleOrDefaultAsync(); + w.ChildItemId = itemid; + return w; - await ValidateAsync(dbObject, SnapshotOfOriginalDBObj); - if (HasErrors) - return false; - - //Log event and save context - await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, BizType, AyaEvent.Modified), ct); - await SearchIndexAsync(dbObject, false); - await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); - - return true; } + // //////////////////////////////////////////////////////////////////////////////////////////////// + // //GET WORKORDER ID FOR WORK ORDER NUMBER + // // + // internal static async Task GetQuoteIdForNumberAsync(long woNumber, AyContext ct) + // { + // return await ct.Quote.AsNoTracking() + // .Where(z => z.Serial == woNumber) + // .Select(z => z.Id) + // .SingleOrDefaultAsync(); + // } + + + //////////////////////////////////////////////////////////////////////////////////////////////// //SEARCH - // - private async Task SearchIndexAsync(Quote obj, bool isNew) + // + private async Task QuoteSearchIndexAsync(Quote obj, bool isNew) { var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, BizType); DigestSearchText(obj, SearchParams); @@ -219,7 +611,7 @@ namespace AyaNova.Biz public async Task GetSearchResultSummary(long id) { - var obj = await ct.Quote.SingleOrDefaultAsync(z => z.Id == id); + var obj = await ct.Quote.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);//# NOTE intentionally not calling quote get async here, don't need the whole graph var SearchParams = new Search.SearchIndexProcessObjectParameters(); DigestSearchText(obj, SearchParams); return SearchParams; @@ -228,35 +620,72 @@ namespace AyaNova.Biz public void DigestSearchText(Quote obj, Search.SearchIndexProcessObjectParameters searchParams) { if (obj != null) - searchParams.AddText(obj.Notes) - .AddText(obj.Serial) - .AddText(obj.Wiki) - .AddText(obj.Tags) - .AddCustomFields(obj.CustomFields); + searchParams.AddText(obj.Notes).AddText(obj.Serial).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); } + + //////////////////////////////////////////////////////////////////////////////////////////////// - //DELETE + // "The Andy" notification helper // - internal async Task DeleteAsync(Quote dbObject) + // (for now this is only for the notification exceeds total so only need one grand total of + // line totals, if in future need more can return a Record object instead with split out + // taxes, net etc etc) + // + private async Task WorkorderGrandTotalAsync(long workOrderId, AyContext ct) { - await Task.CompletedTask; - throw new System.NotImplementedException("STUB: WORKORDER DELETE"); - //Determine if the object can be deleted, do the deletion tentatively - //Probably also in here deal with tags and associated search text etc + var wo = await ct.Quote.AsNoTracking().AsSplitQuery() + .Include(w => w.Items.OrderBy(item => item.Sequence)) + .ThenInclude(wi => wi.Expenses) + .Include(w => w.Items) + .ThenInclude(wi => wi.Labors) + .Include(w => w.Items) + .ThenInclude(wi => wi.Loans) + .Include(w => w.Items) + .ThenInclude(wi => wi.Parts) + .Include(w => w.Items) + .ThenInclude(wi => wi.Travels) + .Include(w => w.Items) + .ThenInclude(wi => wi.OutsideServices) + .SingleOrDefaultAsync(z => z.Id == workOrderId); + if (wo == null) return 0m; - //NOT REQUIRED NOW BUT IF IN FUTURE ValidateCanDelete(dbObject); - // if (HasErrors) - // return false; - // ct.Quote.Remove(dbObject); - // await ct.SaveChangesAsync(); + decimal GrandTotal = 0m; + //update pricing + foreach (QuoteItem wi in wo.Items) + { + foreach (QuoteItemExpense o in wi.Expenses) + await ExpensePopulateVizFields(o, true); + foreach (QuoteItemLabor o in wi.Labors) + await LaborPopulateVizFields(o, true); + foreach (QuoteItemLoan o in wi.Loans) + await LoanPopulateVizFields(o, null, true); + foreach (QuoteItemPart o in wi.Parts) + await PartPopulateVizFields(o, true); + foreach (QuoteItemTravel o in wi.Travels) + await TravelPopulateVizFields(o, true); + foreach (QuoteItemOutsideService o in wi.OutsideServices) + await OutsideServicePopulateVizFields(o, true); + } - // //Log event - // await EventLogProcessor.DeleteObjectLogAsync(UserId, BizType, dbObject.Id, dbObject.Serial.ToString(), ct); - // await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, BizType); - // await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); - // return true; + foreach (QuoteItem wi in wo.Items) + { + foreach (QuoteItemExpense o in wi.Expenses) + GrandTotal += o.LineTotalViz; + foreach (QuoteItemLabor o in wi.Labors) + GrandTotal += o.LineTotalViz; + foreach (QuoteItemLoan o in wi.Loans) + GrandTotal += o.LineTotalViz; + foreach (QuoteItemPart o in wi.Parts) + GrandTotal += o.LineTotalViz; + foreach (QuoteItemTravel o in wi.Travels) + GrandTotal += o.LineTotalViz; + foreach (QuoteItemOutsideService o in wi.OutsideServices) + GrandTotal += o.LineTotalViz; + } + + return GrandTotal; } @@ -266,12 +695,56 @@ namespace AyaNova.Biz // //Can save or update? - private async Task ValidateAsync(Quote proposedObj, Quote currentObj) + private async Task QuoteValidateAsync(Quote proposedObj, Quote currentObj) { + //This may become necessary for v8migrate, leaving out for now + //skip validation if seeding + //if (ServerBootConfig.SEEDING) return; + //run validation and biz rules bool isNew = currentObj == null; + //Check restricted role preventing create + if (isNew && UserIsRestrictedType) + { + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return;//this is a completely disqualifying error + } + + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + + + + /* + + todo: quote status list first, it's a table of created items, keep properties from v7 but add the following properties: + SelectRoles - who can select the status (still shows if they can't select but that's the current status, like active does) + This is best handled at the client. It prefetches all the status out of the normal picklist process, more like how other things are separately handled now without a picklist + client then knows if a status is available or not and can process to only present available ones + #### Server can use a biz rule to ensure that it can't be circumvented + UI defaults to any role + DeselectRoles - who can unset this status (important for process control) + UI defaults to any role + CompletedStatus bool - this is a final status indicating all work on the quote is completed, affects notification etc + UI defaults to false but when set to true auto sets lockworkorder to true (but user can just unset lockworkorder) + LockWorkorder - this status is considered read only and the quote is locked + Just a read only thing, can just change status to "unlock" it + to support states where no one should work on a wo for whatever reason but it's not necessarily completed + e.g. "Hold for inspection", "On hold" generally etc + */ // //Name required // if (string.IsNullOrWhiteSpace(proposedObj.Name)) // AddError(ApiErrorCode.VALIDATION_REQUIRED, "Name"); @@ -304,20 +777,5190 @@ namespace AyaNova.Biz } - //Can delete? - // private void ValidateCanDelete(Quote inObj) + + private void QuoteValidateCanDelete(Quote dbObject) + { + //Check restricted role preventing create + if (UserIsRestrictedType) + { + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return;//this is a completely disqualifying error + } + //FOREIGN KEY CHECKS + //these are examples copied from customer for when other objects are actually referencing them + // if (await ct.User.AnyAsync(m => m.CustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("User")); + // if (await ct.Unit.AnyAsync(m => m.CustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("Unit")); + // if (await ct.CustomerServiceRequest.AnyAsync(m => m.CustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("CustomerServiceRequest")); + // if (await ct.PurchaseOrder.AnyAsync(m => m.DropShipToCustomerId == inObj.Id)) + // AddError(ApiErrorCode.VALIDATION_REFERENTIAL_INTEGRITY, "generalerror", await Translate("PurchaseOrder")); + } + + + //############### NOTIFICATION TODO + /* + + todo: quote notifications remove #30 and #32 as redundant + WorkorderStatusChange = 4,//* Workorder object, any *change* of status including from no status (new) to a specific conditional status ID value + + WorkorderStatusAge = 24,//* Workorder object Created / Updated, conditional on exact status selected IdValue, Tags conditional, advance notice can be set + + //THESE TWO ARE REDUNDANT: + + this is actually workorderstatuschange because can just pick any status under workorderstatuschange to be notified about + WorkorderCompleted = 30, //*travel work order is set to any status that is flagged as a "Completed" type of status. Customer & User + + //This one could be accomplished with WorkorderStatusAge, just pick a Completed status and set a time frame and wala! + WorkorderCompletedFollowUp = 32, //* travel quote closed status follow up again after this many TIMESPAN + + todo: CHANGE WorkorderCompletedStatusOverdue = 15,//* Workorder object not set to a "Completed" flagged quote status type in selected time span from creation of quote + Change this to a new type that is based on so many days *without* being set to a particular status + but first check if tied to contract response time stuff, how that's handled + that's closeby date in v7 but isn't that deprecated now without a "close"? + maybe I do need the Completed status bool thing above + + */ + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET PARTIAL WORKORDER FOR REPORTING + // (returns quote consisting only of the path from child or grandchild up to header populated + // with display data for reporting) + // + internal async Task QuoteGetPartialAsync(AyaType ayaType, long id, bool includeWoItemDescendants, bool populateForReporting) + { + //if it's the entire quote just get, populate and return as normal + if (ayaType == AyaType.Quote) + return await QuoteGetAsync(id, true, false, populateForReporting); + + var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); + + //get header only + var ret = await ct.Quote.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.ParentId); + + //not found don't bomb, just return null + if (ret == null) return ret; + + //explicit load subitems as required... + + + QuoteItem quoteitem = null; + + //it's requesting a fully populated woitem so do that here + if (includeWoItemDescendants) + { + + quoteitem = await ct.QuoteItem.AsSplitQuery() + .AsNoTracking() + .Include(wi => wi.Expenses) + .Include(wi => wi.Labors) + .Include(wi => wi.Loans) + .Include(wi => wi.Parts) + .Include(wi => wi.ScheduledUsers) + .Include(wi => wi.Tasks) + .Include(wi => wi.Travels) + .Include(wi => wi.Units) + .Include(wi => wi.OutsideServices) + .SingleOrDefaultAsync(z => z.Id == wid.ChildItemId); + } + else + { + + //get the single quote item required + quoteitem = await ct.QuoteItem.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.ChildItemId); + + switch (ayaType) + { + case AyaType.QuoteItemExpense: + quoteitem.Expenses.Add(await ct.QuoteItemExpense.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.QuoteItemLabor: + quoteitem.Labors.Add(await ct.QuoteItemLabor.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.QuoteItemLoan: + quoteitem.Loans.Add(await ct.QuoteItemLoan.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.QuoteItemPart: + quoteitem.Parts.Add(await ct.QuoteItemPart.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + + case AyaType.QuoteItemScheduledUser: + quoteitem.ScheduledUsers.Add(await ct.QuoteItemScheduledUser.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.QuoteItemTask: + quoteitem.Tasks.Add(await ct.QuoteItemTask.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.QuoteItemTravel: + quoteitem.Travels.Add(await ct.QuoteItemTravel.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.QuoteItemOutsideService: + quoteitem.OutsideServices.Add(await ct.QuoteItemOutsideService.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + case AyaType.QuoteItemUnit: + quoteitem.Units.Add(await ct.QuoteItemUnit.AsNoTracking().Where(z => z.Id == id).SingleOrDefaultAsync()); + break; + } + } + + if (quoteitem != null) + ret.Items.Add(quoteitem); + + await QuotePopulateVizFields(ret, false, populateForReporting); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //REPORTING + // + public async Task GetReportData(DataListSelectedRequest dataListSelectedRequest) + { + //quote reports for entire quote or just sub parts all go through here + //if the ayatype is a descendant of the quote then only the portion of the quote from that descendant directly up to the header will be populated and returned + //however if the report template has includeWoItemDescendants=true then the woitems is fully populated + + var idList = dataListSelectedRequest.SelectedRowIds; + JArray ReportData = new JArray(); + + while (idList.Any()) + { + var batch = idList.Take(IReportAbleObject.REPORT_DATA_BATCH_SIZE); + idList = idList.Skip(IReportAbleObject.REPORT_DATA_BATCH_SIZE).ToArray(); + List batchResults = new List(); + foreach (long batchId in batch) + batchResults.Add(await QuoteGetPartialAsync(dataListSelectedRequest.AType, batchId, dataListSelectedRequest.IncludeWoItemDescendants, true)); + + #region unnecessary shit removed + //This is unnecessary because the re-ordering bit is only needed when the records are fetched in batches directly from the sql server as they + //return in db natural order and need to be put back into the same order as the ID List + //Here in the quote however, this code is fetching individually one at a time so they are always going to be in the correct order so this re-ordering is unnecessary + //I'm keeping this here for future reference when I ineveitably wonder what the hell is happening here :) + + + //order the results back into original + //IEnumerable orderedList = null; + + //TODO: WHAT IS THIS BATCH RESULT ORDERING CODE REALLY DOING AND CAN IT BE REMOVED / CHANGED???? + //isn't it alredy working in order? If not maybe simply reversed so reverse it again before querying above or...?? + + //todo: can't assume the grandchild item is index 0 anymore as we might have multiple of them if includedescendants is true + //so need to find index first then do this + // switch (dataListSelectedRequest.AType) + // { + // case AyaType.Quote: + // orderedList = from id in batch join z in batchResults on id equals z.Id select z; + // break; + // case AyaType.QuoteItem: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Id select z; + // break; + // case AyaType.QuoteItemExpense: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Expenses[0].Id select z; + // break; + // case AyaType.QuoteItemLabor: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Labors[0].Id select z; + // break; + // case AyaType.QuoteItemLoan: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Loans[0].Id select z; + // break; + // case AyaType.QuoteItemPart: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Parts[0].Id select z; + // break; + // case AyaType.QuoteItemPartRequest: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].PartRequests[0].Id select z; + // break; + // case AyaType.QuoteItemScheduledUser: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].ScheduledUsers[0].Id select z; + // break; + // case AyaType.QuoteItemTask: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Tasks[0].Id select z; + // break; + // case AyaType.QuoteItemTravel: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Travels[0].Id select z; + // break; + // case AyaType.QuoteItemOutsideService: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].OutsideServices[0].Id select z; + // break; + // case AyaType.QuoteItemUnit: + // orderedList = from id in batch join z in batchResults on id equals z.Items[0].Units[0].Id select z; + // break; + // } + + //foreach (Quote w in orderedList) + #endregion unnecessary shit + + foreach (Quote w in batchResults) + { + var jo = JObject.FromObject(w); + + //Quote header custom fields + if (!JsonUtil.JTokenIsNullOrEmpty(jo["CustomFields"])) + jo["CustomFields"] = JObject.Parse((string)jo["CustomFields"]); + + //QuoteItem custom fields + foreach (JObject jItem in jo["Items"]) + { + if (!JsonUtil.JTokenIsNullOrEmpty(jItem["CustomFields"])) + jItem["CustomFields"] = JObject.Parse((string)jItem["CustomFields"]); + + //QuoteItemUnit custom fields + foreach (JObject jUnit in jItem["Units"]) + { + if (!JsonUtil.JTokenIsNullOrEmpty(jUnit["CustomFields"])) + jUnit["CustomFields"] = JObject.Parse((string)jUnit["CustomFields"]); + } + } + ReportData.Add(jo); + } + } + return ReportData; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task QuotePopulateVizFields(Quote o, bool headerOnly, bool populateForReporting) + { + + + o.UserIsRestrictedType = UserIsRestrictedType; + o.UserIsTechRestricted = UserIsTechRestricted; + o.UserIsSubContractorFull = UserIsSubContractorFull; + o.UserIsSubContractorRestricted = UserIsSubContractorRestricted; + o.UserCanViewPartCosts = UserCanViewPartCosts; + o.UserCanViewLaborOrTravelRateCosts = UserCanViewLaborOrTravelRateCosts; + o.UserCanViewLoanerCosts = UserCanViewLoanerCosts; + + if (!headerOnly) + { + foreach (var v in o.States) + await StatePopulateVizFields(v); + foreach (var v in o.Items) + await ItemPopulateVizFields(v, populateForReporting); + } + + //popup Alert notes + //Customer notes first then others below + var custInfo = await ct.Customer.AsNoTracking().Where(x => x.Id == o.CustomerId).Select(x => new { AlertViz = x.PopUpNotes, x.TechNotes, CustomerViz = x.Name }).FirstOrDefaultAsync(); + if (!string.IsNullOrWhiteSpace(custInfo.AlertViz)) + { + o.AlertViz = $"{await Translate("Customer")} - {await Translate("AlertNotes")}\n{custInfo.AlertViz}\n\n"; + } + + if (!string.IsNullOrWhiteSpace(custInfo.TechNotes)) + { + o.CustomerTechNotesViz = $"{await Translate("CustomerTechNotes")}\n{custInfo.TechNotes}\n\n"; + } + + o.CustomerViz = custInfo.CustomerViz; + + if (o.ProjectId != null) + o.ProjectViz = await ct.Project.AsNoTracking().Where(x => x.Id == o.ProjectId).Select(x => x.Name).FirstOrDefaultAsync(); + + if (o.ContractId != null) + { + var contractVizFields = await ct.Contract.AsNoTracking().Where(x => x.Id == o.ContractId).Select(x => new { Name = x.Name, AlertNotes = x.AlertNotes }).FirstOrDefaultAsync(); + o.ContractViz = contractVizFields.Name; + if (!string.IsNullOrWhiteSpace(contractVizFields.AlertNotes)) + { + o.AlertViz += $"{await Translate("Contract")}\n{contractVizFields.AlertNotes}\n\n"; + } + } + else + o.ContractViz = "-"; + + + + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // IMPORT EXPORT + // + + public async Task GetExportData(DataListSelectedRequest dataListSelectedRequest) + { + //for now just re-use the report data code + //this may turn out to be the pattern for most biz object types but keeping it seperate allows for custom usage from time to time + return await GetReportData(dataListSelectedRequest); + } + + // public async Task> ImportData(JArray ja) // { - // //whatever needs to be check to delete this object + // List ImportResult = new List(); + // string ImportTag = $"imported-{FileUtil.GetSafeDateFileName()}"; + + // var jsset = JsonSerializer.CreateDefault(new JsonSerializerSettings { ContractResolver = new AyaNova.Util.JsonUtil.ShouldSerializeContractResolver(new string[] { "Concurrency", "Id", "CustomFields" }) }); + // foreach (JObject j in ja) + // { + // var w = j.ToObject(jsset); + // if (j["CustomFields"] != null) + // w.CustomFields = j["CustomFields"].ToString(); + // w.Tags.Add(ImportTag);//so user can find them all and revert later if necessary + // var res = await QuoteCreateAsync(w); + // if (res == null) + // { + // ImportResult.Add($"* {w.Serial} - {this.GetErrorsAsString()}"); + // this.ClearErrors(); + // } + // else + // { + // ImportResult.Add($"{w.Serial} - ok"); + // } + // } + // return ImportResult; // } - - //////////////////////////////////////////////////////////////////////////////////////////////// //JOB / OPERATIONS // + public async Task HandleJobAsync(OpsJob job) + { + switch (job.JobType) + { + case JobType.BatchCoreObjectOperation: + await ProcessBatchJobAsync(job); + break; + default: + throw new System.ArgumentOutOfRangeException($"Quote.HandleJob-> Invalid job type{job.JobType.ToString()}"); + } + } + + + private async Task ProcessBatchJobAsync(OpsJob job) + { + await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Running); + await JobsBiz.LogJobAsync(job.GId, $"LT:StartJob {job.SubType}"); + List idList = new List(); + long FailedObjectCount = 0; + JObject jobData = JObject.Parse(job.JobInfo); + if (jobData.ContainsKey("idList")) + idList = ((JArray)jobData["idList"]).ToObject>(); + else + idList = await ct.Widget.Select(z => z.Id).ToListAsync(); + bool SaveIt = false; + foreach (long id in idList) + { + try + { + SaveIt = false; + ClearErrors(); + ICoreBizObjectModel o = null; + //save a fetch if it's a delete + if (job.SubType != JobSubType.Delete) + o = await GetQuoteGraphItem(job.AType, id); + switch (job.SubType) + { + case JobSubType.TagAddAny: + case JobSubType.TagAdd: + case JobSubType.TagRemoveAny: + case JobSubType.TagRemove: + case JobSubType.TagReplaceAny: + case JobSubType.TagReplace: + SaveIt = TagBiz.ProcessBatchTagOperation(o.Tags, (string)jobData["tag"], jobData.ContainsKey("toTag") ? (string)jobData["toTag"] : null, job.SubType); + break; + case JobSubType.Delete: + if (!await DeleteQuoteGraphItem(job.AType, id)) + { + await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}"); + FailedObjectCount++; + } + break; + default: + throw new System.ArgumentOutOfRangeException($"ProcessBatchJobAsync -> Invalid job Subtype{job.SubType}"); + } + if (SaveIt) + { + o = await PutQuoteGraphItem(job.AType, o); + if (o == null) + { + await JobsBiz.LogJobAsync(job.GId, $"LT:Errors {GetErrorsAsString()} id {id}"); + FailedObjectCount++; + } + } + } + catch (Exception ex) + { + await JobsBiz.LogJobAsync(job.GId, $"LT:Errors id({id})"); + await JobsBiz.LogJobAsync(job.GId, ExceptionUtil.ExtractAllExceptionMessages(ex)); + } + } + await JobsBiz.LogJobAsync(job.GId, $"LT:BatchJob {job.SubType} {idList.Count}{(FailedObjectCount > 0 ? " - LT:Failed " + FailedObjectCount : "")}"); + await JobsBiz.UpdateJobStatusAsync(job.GId, JobStatus.Completed); + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task QuoteHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{this.BizType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + + Quote oProposed = (Quote)proposedObj; + proposedObj.Name = oProposed.Serial.ToString(); + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct);//Note: will properly handle all delete events and event removal if deleted + + //SPECIFIC EVENTS FOR THIS OBJECT + + // Quote oCurrent = null; + // bool SameTags = true; + // if (currentObj != null) + // { + // oCurrent = (Quote)currentObj; + // SameTags = NotifyEventHelper.TwoObjectsHaveSameTags(proposedObj.Tags, currentObj.Tags); + // } + + + + }//end of process notifications + + + + #endregion quote level + + + + + /* + + ███████╗████████╗ █████╗ ████████╗███████╗███████╗ + ██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝██╔════╝ + ███████╗ ██║ ███████║ ██║ █████╗ ███████╗ + ╚════██║ ██║ ██╔══██║ ██║ ██╔══╝ ╚════██║ + ███████║ ██║ ██║ ██║ ██║ ███████╗███████║ + ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝ + + */ + + + #region QuoteState level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task StateExistsAsync(long id) + { + return await ct.QuoteState.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task StateCreateAsync(QuoteState newObject) + { + await StateValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await ct.QuoteState.AddAsync(newObject); + var qoute = await ct.Quote.FirstOrDefaultAsync(x => x.Id == newObject.QuoteId); + var newStatusInfo = await ct.QuoteStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == newObject.QuoteStatusId); + + + + qoute.LastStatusId = newObject.QuoteStatusId; + + await ct.SaveChangesAsync(); + + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, AyaType.QuoteStatus, AyaEvent.Created), ct); + await StateHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + return newObject; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task StateGetAsync(long id, bool logTheGetEvent = true) + { + //Note: there could be rules checking here in future, i.e. can only get own quote or something + //if so, then need to implement AddError and in route handle Null return with Error check just like PUT route does now + + //https://docs.microsoft.com/en-us/ef/core/querying/related-data + //docs say this will not query twice but will recognize the duplicate woitem bit which is required for multiple grandchild collections + var ret = await ct.QuoteState.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, AyaType.QuoteStatus, AyaEvent.Retrieved), ct); + return ret; + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task StatePopulateVizFields(QuoteState o) + { + + o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); + // if (o.QuoteOverseerId != null) + // o.QuoteOverseerViz = await ct.User.AsNoTracking().Where(x => x.Id == o.QuoteOverseerId).Select(x => x.Name).FirstOrDefaultAsync(); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // (note: this would only ever be called when a quote is deleted, there is no direct delete) + internal async Task StateDeleteAsync(long workOrderId, IDbContextTransaction parentTransaction) + { + try + { + var stateList = await ct.QuoteState.AsNoTracking().Where(z => z.QuoteId == workOrderId).ToListAsync(); + + foreach (var wostate in stateList) + { + ct.QuoteState.Remove(wostate); + await ct.SaveChangesAsync(); + //no need to call this because it's only going to run this method if the quote is deleted and + //via process standard notifciation events for quote deletion will remove any state delayed notifications anyway so + //nothing to call or do here related to notification + // await StateHandlePotentialNotificationEvent(AyaEvent.Deleted, wostate); + } + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task StateValidateAsync(QuoteState proposedObj, QuoteState currentObj) + { + + //of all restricted users, only a restricted tech can change status + if (UserIsSubContractorFull || UserIsSubContractorRestricted) + { + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + //run validation and biz rules + bool isNew = currentObj == null; + + //does it have a valid quote id + if (proposedObj.QuoteId == 0) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteId"); + else if (!await QuoteExistsAsync(proposedObj.QuoteId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteId"); + } + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task StateHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteState oProposed = (QuoteState)proposedObj; + + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == oProposed.QuoteId).Select(x => new { x.Serial, x.Tags, x.CustomerId }).FirstOrDefaultAsync(); + QuoteStatus wos = await ct.QuoteStatus.AsNoTracking().FirstOrDefaultAsync(x => x.Id == oProposed.QuoteStatusId); + //for notification purposes because has no name / tags field itself + oProposed.Name = WorkorderInfo.Serial.ToString(); + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + //NONE: state notifications are specific and not the same as for general objects so don't process standard events + + //SPECIFIC EVENTS FOR THIS OBJECT + //WorkorderStatusChange = 4,//*Workorder object, any NEW status set. Conditions: specific status ID value only (no generic any status allowed), Workorder TAGS + //WorkorderCompletedStatusOverdue = 15,//* Workorder object not set to a "Completed" flagged quote status type in selected time span from creation of workorderWorkorderSetToCompletedStatus + //WorkorderStatusAge = 24,//* Workorder STATUS unchanged for set time (stuck in state), conditional on: Duration (how long stuck), exact status selected IdValue, Tags. Advance notice can NOT be set + + //NOTE: ID, state notifications are for the Workorder, not the state itself unlike other objects, so use the WO type and ID here for all notifications + + + + + + //## DELETED EVENTS + //A state cannot be deleted so nothing to handle that is required + //a quote CAN be deleted and it will automatically remove all events for it so also no need to remove time delayed status events either if wo is deleted. + //so in essence there is nothing to be done regarding deleted events with states in a blanket way, however specific events below may remove them as appropriate + + + // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.WorkorderStatusChange); + // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.WorkorderCompletedStatusOverdue); + // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.WorkorderStatusAge); + + + //## CREATED (this is the only possible notification CREATION ayaEvent type for a quote state as they are create only) + if (ayaEvent == AyaEvent.Created) + { + //# STATUS CHANGE (create new status) + { + //Conditions: must match specific status id value and also tags below + //delivery is immediate so no need to remove old ones of this kind + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.WorkorderStatusChange && z.IdValue == oProposed.QuoteStatusId).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.WorkorderStatusChange, + UserId = sub.UserId, + AyaType = AyaType.Quote, + ObjectId = oProposed.QuoteId, + NotifySubscriptionId = sub.Id, + Name = $"{WorkorderInfo.Serial.ToString()} - {wos.Name}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//quote status change event + + //# STATUS AGE + { + //WorkorderStatusAge = 24,//* Workorder STATUS unchanged for set time (stuck in state), conditional on: Duration (how long stuck), exact status selected IdValue, Tags. Advance notice can NOT be set + //Always clear any old ones for this object as they are all irrelevant the moment the state has changed: + await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.WorkorderStatusAge); + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.WorkorderStatusAge && z.IdValue == oProposed.QuoteStatusId).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Quote Tag match? (Not State, state has no tags, will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.WorkorderStatusAge, + UserId = sub.UserId, + AyaType = AyaType.Quote, + ObjectId = oProposed.QuoteId, + NotifySubscriptionId = sub.Id, + Name = $"{WorkorderInfo.Serial.ToString()} - {wos.Name}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//quote status change event + + + //# COMPLETE BY OVERDUE + { + //NOTE: the initial notification is created by the Workorder Header notification as it's where this time delayed notification is first generated + //the only job here in state notification is to remove any prior finish overdue notifications waiting if a new state is selected that is a completed state + + //NOTE ABOUT RE-OPEN DECISION ON HOW THIS WORKS: + + //what though if it's not a Completed status, then I guess don't remove it, but what if it *was* a Completed status and it's change to a non Completed? + //that, in essence re-opens it so it's not Completed at that point. + //My decision on this june 2021 is that a work order Completed status notification is satisifed the moment it's saved with a Completed status + //and nothing afterwards restarts that process so if a person sets closed status then sets open status again no new Completed overdue notification will be generated + + if (wos.Completed) + { + //Workorder was just set to a completed status so remove any notify events lurking to deliver for overdue + await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, oProposed.QuoteId, NotifyEventType.WorkorderCompletedStatusOverdue); + } + }//quote complete by overdue change event + + + //# WorkorderTotalExceedsThreshold / "The Andy" + { + if (wos.Completed) + { + + //see if any subscribers to the quote total exceeds notification + //that are active then proceed to fetch billed woitem children and total quote and send notification if necessary + + bool haveTotal = false; + decimal GrandTotal = 0m; + + //look for potential subscribers + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.WorkorderTotalExceedsThreshold).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + //check early to avoid cost of fetching and calculating total if unnecessary + if (!NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) continue; + + //get the total because we have at least one subscriber and matching tags + if (haveTotal == false) + { + GrandTotal = await WorkorderGrandTotalAsync(oProposed.QuoteId, ct); + haveTotal = true; + + //Note: not a time delayed notification, however user could be flipping states quickly triggering multiple notifications that are in queue temporarily + //so this will prevent that: + await NotifyEventHelper.ClearPriorEventsForObject(ct, AyaType.Quote, oProposed.QuoteId, NotifyEventType.WorkorderTotalExceedsThreshold); + } + //Ok, we're here because there is a subscriber who is active and tags match so only check left is total against decvalue + if (sub.DecValue < GrandTotal) + { + //notification is a go + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.WorkorderTotalExceedsThreshold, + UserId = sub.UserId, + AyaType = AyaType.Quote, + ObjectId = oProposed.QuoteId, + NotifySubscriptionId = sub.Id, + Name = $"{WorkorderInfo.Serial.ToString()}", + DecValue = GrandTotal + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + } + }//"The Andy" for Dynamic Dental corp. notification + + + //# WorkorderCompleted - Customer AND User but customer only notifies if it's their quote + { + if (wos.Completed) + { + //look for potential subscribers + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.WorkorderCompleted).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Customer User? + var UserInfo = await ct.User.AsNoTracking().Where(x => x.Id == sub.UserId).Select(x => new { x.CustomerId, x.UserType, x.HeadOfficeId }).FirstOrDefaultAsync(); + if (UserInfo.UserType == UserType.Customer || UserInfo.UserType == UserType.HeadOffice) + { + //CUSTOMER USER + + //Quick short circuit: if quote doesn't have a customer id then it's not going to match no matter what + if (WorkorderInfo.CustomerId == 0) continue; + + var customerUserRights = await UserBiz.CustomerUserEffectiveRightsAsync(sub.UserId); + + //Are they allowed right now to use this type of notification? + if (!customerUserRights.NotifyWOCompleted) continue; + + //is this their related work order? + if (UserInfo.CustomerId != WorkorderInfo.CustomerId) + { + //not the same customer but might be a head office user and this is one of their customers so check for that + if (UserInfo.HeadOfficeId == null) continue;//can't match any head office so no need to go further + + //see if quote customer's head office is the same id as the user's headofficeid (note that a customer user with the same head office as a *different* customer quote doesn't qualify) + var CustomerInfo = await ct.Customer.AsNoTracking().Where(x => x.Id == WorkorderInfo.CustomerId).Select(x => new { x.HeadOfficeId, x.BillHeadOffice }).FirstOrDefaultAsync(); + if (!CustomerInfo.BillHeadOffice) continue;//can't possibly match so no need to go further + if (UserInfo.HeadOfficeId != CustomerInfo.HeadOfficeId) continue; + } + } + else + { + //INSIDE USER + //Tag match? (will be true if no sub tags so always safe to call this) + //check early to avoid cost of fetching and calculating total if unnecessary + if (!NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) continue; + } + + //Ok, we're here so it must be ok to notify user + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.WorkorderCompleted, + UserId = sub.UserId, + AyaType = AyaType.Quote, + ObjectId = oProposed.QuoteId, + NotifySubscriptionId = sub.Id, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//WorkorderCompleted + } + + }//end of process notifications + + #endregion work order STATE level + + + /* + ██╗████████╗███████╗███╗ ███╗███████╗ + ██║╚══██╔══╝██╔════╝████╗ ████║██╔════╝ + ██║ ██║ █████╗ ██╔████╔██║███████╗ + ██║ ██║ ██╔══╝ ██║╚██╔╝██║╚════██║ + ██║ ██║ ███████╗██║ ╚═╝ ██║███████║ + ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝ + */ + + #region QuoteItem level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task ItemExistsAsync(long id) + { + return await ct.QuoteItem.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task ItemCreateAsync(QuoteItem newObject) + { + await ItemValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.QuoteItem.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, AyaType.QuoteItem, AyaEvent.Created), ct); + await ItemSearchIndexAsync(newObject, true); + await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await ItemHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await ItemPopulateVizFields(newObject, false); + return newObject; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task ItemGetAsync(long id, bool logTheGetEvent = true) + { + + //Restricted users can not fetch a woitem directly + //arbitrary decision so don't have to put in all the cleanup code + //because from our own UI they wouldn't fetch this anyway and + //so this is only to cover api use by 3rd parties + if (UserIsRestrictedType) + { + return null; + } + + //Note: there could be rules checking here in future, i.e. can only get own quote or something + //if so, then need to implement AddError and in route handle Null return with Error check just like PUT route does now + + //https://docs.microsoft.com/en-us/ef/core/querying/related-data + //docs say this will not query twice but will recognize the duplicate woitem bit which is required for multiple grandchild collections + var ret = + await ct.QuoteItem.AsSplitQuery().AsNoTracking() + .Include(wi => wi.Expenses) + .Include(wi => wi.Labors) + .Include(wi => wi.Loans) + .Include(wi => wi.Parts) + .Include(wi => wi.ScheduledUsers) + .Include(wi => wi.Tasks) + .Include(wi => wi.Travels) + .Include(wi => wi.Units) + .Include(wi => wi.OutsideServices) + .SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, AyaType.QuoteItem, AyaEvent.Retrieved), ct); + return ret; + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task ItemPutAsync(QuoteItem putObject) + { + //Note: this is intentionally not using the getasync because + //doing so would also fetch the children which would then get deleted on save since putobject has no children + var dbObject = await ct.QuoteItem.AsNoTracking().FirstOrDefaultAsync(z => z.Id == putObject.Id); + + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + putObject.Tags = TagBiz.NormalizeTags(putObject.Tags); + putObject.CustomFields = JsonUtil.CompactJson(putObject.CustomFields); + + await ItemValidateAsync(putObject, dbObject); + if (HasErrors) return null; + ct.Replace(dbObject, putObject); + + + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await ItemExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, AyaType.QuoteItem, AyaEvent.Modified), ct); + await ItemSearchIndexAsync(putObject, false); + await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); + await ItemHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await ItemPopulateVizFields(putObject, false); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task ItemDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await ct.QuoteItem.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND); + return false; + } + ItemValidateCanDelete(dbObject); + if (HasErrors) + return false; + + //collect the child id's to delete + var ExpenseIds = await ct.QuoteItemExpense.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + var LaborIds = await ct.QuoteItemLabor.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + var LoanIds = await ct.QuoteItemLoan.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + var PartIds = await ct.QuoteItemPart.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + + var ScheduledUserIds = await ct.QuoteItemScheduledUser.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + var TaskIds = await ct.QuoteItemTask.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + var TravelIds = await ct.QuoteItemTravel.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + var UnitIds = await ct.QuoteItemUnit.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + var OutsideServiceIds = await ct.QuoteItemOutsideService.Where(z => z.QuoteItemId == id).Select(z => z.Id).ToListAsync(); + + //Delete children + foreach (long ItemId in ExpenseIds) + if (!await ExpenseDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in LaborIds) + if (!await LaborDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in LoanIds) + if (!await LoanDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in PartIds) + if (!await PartDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in ScheduledUserIds) + if (!await ScheduledUserDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in TaskIds) + if (!await TaskDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in TravelIds) + if (!await TravelDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in UnitIds) + if (!await UnitDeleteAsync(ItemId, transaction)) + return false; + foreach (long ItemId in OutsideServiceIds) + if (!await OutsideServiceDeleteAsync(ItemId, transaction)) + return false; + + ct.QuoteItem.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "wo:" + dbObject.QuoteId.ToString(), ct);//FIX wo?? Not sure what is best here; revisit + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); + + //all good do the commit if it's ours + if (parentTransaction == null) + await transaction.CommitAsync(); + await ItemHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + private async Task ItemSearchIndexAsync(QuoteItem obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, AyaType.QuoteItem); + SearchParams.AddText(obj.Notes).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task ItemGetSearchResultSummary(long id) + { + var obj = await ct.QuoteItem.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id);//# Note, intentionally not calling ItemGetAsync here as don't want whole graph + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.Notes).AddText(obj.Wiki).AddText(obj.Tags).AddCustomFields(obj.CustomFields); + return SearchParams; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task ItemPopulateVizFields(QuoteItem o, bool populateForReporting) + { + // if (o.QuoteOverseerId != null) + // o.QuoteOverseerViz = await ct.User.AsNoTracking().Where(x => x.Id == o.QuoteOverseerId).Select(x => x.Name).FirstOrDefaultAsync(); + + foreach (var v in o.Expenses) + await ExpensePopulateVizFields(v); + foreach (var v in o.Labors) + await LaborPopulateVizFields(v); + foreach (var v in o.Loans) + await LoanPopulateVizFields(v); + foreach (var v in o.OutsideServices) + await OutsideServicePopulateVizFields(v); + foreach (var v in o.Parts) + await PartPopulateVizFields(v); + foreach (var v in o.ScheduledUsers) + await ScheduledUserPopulateVizFields(v); + foreach (var v in o.Tasks) + await TaskPopulateVizFields(v); + foreach (var v in o.Travels) + await TravelPopulateVizFields(v); + foreach (var v in o.Units) + await UnitPopulateVizFields(v, populateForReporting); + + + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task ItemValidateAsync(QuoteItem proposedObj, QuoteItem currentObj) + { + //run validation and biz rules + bool isNew = currentObj == null; + + //does it have a valid quote id + if (proposedObj.QuoteId == 0) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteId"); + else if (!await QuoteExistsAsync(proposedObj.QuoteId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteId"); + } + + //summary is required now, this is a change from v7 + //I did this because it is required in terms of hiding on the form so it also + //is required to have a value. This is really because the form field customization I took away the hideable field + //maybe I should add that feature back? + if (proposedObj.QuoteId == 0) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteId"); + + //Check restricted role preventing create + if (isNew && UserIsRestrictedType) + { + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return;//this is a completely disqualifying error + } + + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (string.IsNullOrWhiteSpace(proposedObj.Notes))//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Notes"); + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItem.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void ItemValidateCanDelete(QuoteItem obj) + { + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + //Check restricted role preventing create + if (UserIsRestrictedType) + { + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return;//this is a completely disqualifying error + } + + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItem)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task ItemHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + + QuoteItem oProposed = (QuoteItem)proposedObj; + + var qid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == qid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + //for notification purposes because has no name field itself + oProposed.Name = WorkorderInfo.Serial.ToString(); + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + //## DELETED EVENTS + //any event added below needs to be removed, so + //just blanket remove any event for this object of eventtype that would be added below here + //do it regardless any time there's an update and then + //let this code below handle the refreshing addition that could have changes + // await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, o.Id, NotifyEventType.ContractExpiring); + + + //## CREATED / MODIFIED EVENTS + if (ayaEvent == AyaEvent.Created || ayaEvent == AyaEvent.Modified) + { + + //todo: fix etc, tons of shit here incoming + + } + + }//end of process notifications + + + + #endregion work order item level + + + /* + ███████╗██╗ ██╗██████╗ ███████╗███╗ ██╗███████╗███████╗███████╗ + ██╔════╝╚██╗██╔╝██╔══██╗██╔════╝████╗ ██║██╔════╝██╔════╝██╔════╝ + █████╗ ╚███╔╝ ██████╔╝█████╗ ██╔██╗ ██║███████╗█████╗ ███████╗ + ██╔══╝ ██╔██╗ ██╔═══╝ ██╔══╝ ██║╚██╗██║╚════██║██╔══╝ ╚════██║ + ███████╗██╔╝ ██╗██║ ███████╗██║ ╚████║███████║███████╗███████║ + ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═══╝╚══════╝╚══════╝╚══════╝ + */ + + #region QuoteItemExpense level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task ExpenseExistsAsync(long id) + { + return await ct.QuoteItemExpense.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task ExpenseCreateAsync(QuoteItemExpense newObject) + { + await ExpenseValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await ct.QuoteItemExpense.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await ExpenseSearchIndexAsync(newObject, true); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await ExpenseHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await ExpensePopulateVizFields(newObject); + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task ExpenseGetAsync(long id, bool logTheGetEvent = true) + { + if (UserIsSubContractorFull || UserIsSubContractorRestricted) //no access allowed at all + return null; + var ret = await ct.QuoteItemExpense.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (UserIsTechRestricted && ret.UserId != UserId)//tech restricted can only see their own expenses + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return null; + } + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task ExpensePutAsync(QuoteItemExpense putObject) + { + var dbObject = await ExpenseGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + await ExpenseValidateAsync(putObject, dbObject); + if (HasErrors) return null; + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await ExpenseExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await ExpenseSearchIndexAsync(putObject, false); + await ExpenseHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await ExpensePopulateVizFields(putObject); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task ExpenseDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await ExpenseGetAsync(id, false); + ExpenseValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemExpense.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await ExpenseHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + ////////////////////////////////////////////// + //INDEXING + // + private async Task ExpenseSearchIndexAsync(QuoteItemExpense obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.Name).AddText(obj.Description); + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task ExpenseGetSearchResultSummary(long id) + { + var obj = await ExpenseGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.Description).AddText(obj.Name); + return SearchParams; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task ExpensePopulateVizFields(QuoteItemExpense o, bool calculateTotalsOnly = false) + { + if (calculateTotalsOnly == false) + { + if (o.UserId != null) + o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); + } + TaxCode Tax = null; + if (o.ChargeTaxCodeId != null) + Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.ChargeTaxCodeId); + if (Tax != null) + o.TaxCodeViz = Tax.Name; + + //Calculate totals and taxes + if (o.ChargeToCustomer) + { + o.TaxAViz = 0; + o.TaxBViz = 0; + + if (Tax != null) + { + if (Tax.TaxAPct != 0) + { + o.TaxAViz = o.ChargeAmount * (Tax.TaxAPct / 100); + } + if (Tax.TaxBPct != 0) + { + if (Tax.TaxOnTax) + { + o.TaxBViz = (o.ChargeAmount + o.TaxAViz) * (Tax.TaxBPct / 100); + } + else + { + o.TaxBViz = o.ChargeAmount * (Tax.TaxBPct / 100); + } + } + o.LineTotalViz = o.ChargeAmount + o.TaxAViz + o.TaxBViz; + } + else + { + o.LineTotalViz = o.ChargeAmount + o.TaxPaid; + } + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task ExpenseValidateAsync(QuoteItemExpense proposedObj, QuoteItemExpense currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (UserIsSubContractorFull || UserIsSubContractorRestricted) + { + //no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (UserIsRestrictedType && (proposedObj.UserId != UserId || (!isNew && currentObj.UserId != UserId))) + { + //no edits allowed on other people's records + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (!isNew && UserIsTechRestricted) + { + //Existing record so just make sure they haven't changed the not changeable fields from the db version + + //Expenses: add (no user selection defaults to themselves), view, partial fields available + // to edit or delete only where they are the selected user and only edit fields + //Summary, Cost, Tax paid, Description + //note that UI will prevent this, this rule is only backup for 3rd party api users + + if (currentObj.ChargeAmount != proposedObj.ChargeAmount) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeAmount"); + //if (currentObj.TaxPaid != proposedObj.TaxPaid) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "TaxPaid"); + if (currentObj.ChargeTaxCodeId != proposedObj.ChargeTaxCodeId) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeTaxCodeId"); + if (currentObj.ReimburseUser != proposedObj.ReimburseUser) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ReimburseUser"); + if (currentObj.ChargeToCustomer != proposedObj.ChargeToCustomer) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeToCustomer"); + } + + if (isNew && UserIsTechRestricted) + { + //NEW record, they are not allowed to set several fields so make sure they are still at their defaults + /* from client new expense record: + concurrency: 0, + description: null, + name: null, + totalCost: 0, + chargeAmount: 0, + taxPaid: 0, + chargeTaxCodeId: null, + taxCodeViz: null, + reimburseUser: false, + userId: null, + userViz: null, + chargeToCustomer: false, + isDirty: true, + workOrderItemId: this.value.items[this.activeWoItemIndex].id, + uid: Date.now() //used for + */ + if (proposedObj.ChargeAmount != 0) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeAmount"); + // if (proposedObj.TaxPaid != 0) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "TaxPaid"); + if (proposedObj.ChargeTaxCodeId != null) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeTaxCodeId"); + if (proposedObj.ReimburseUser != false) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ReimburseUser"); + if (proposedObj.ChargeToCustomer != false) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "ChargeToCustomer"); + + } + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemExpense.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + // CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + private void ExpenseValidateCanDelete(QuoteItemExpense obj) + { + if (UserIsSubContractorFull || UserIsSubContractorRestricted) + { + //no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + if (UserIsTechRestricted && obj.UserId != UserId) + { + //no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemExpense)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task ExpenseHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemExpense oProposed = (QuoteItemExpense)proposedObj; + var qid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == qid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + + }//end of process notifications + #endregion work order item EXPENSE level + + + /* + ██╗ █████╗ ██████╗ ██████╗ ██████╗ + ██║ ██╔══██╗██╔══██╗██╔═══██╗██╔══██╗ + ██║ ███████║██████╔╝██║ ██║██████╔╝ + ██║ ██╔══██║██╔══██╗██║ ██║██╔══██╗ + ███████╗██║ ██║██████╔╝╚██████╔╝██║ ██║ + ╚══════╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ + */ + + #region QuoteItemLabor level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task LaborExistsAsync(long id) + { + return await ct.QuoteItemLabor.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task LaborCreateAsync(QuoteItemLabor newObject) + { + await LaborValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + // await LaborBizActionsAsync(AyaEvent.Created, newObject, null, null); + //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.QuoteItemLabor.AddAsync(newObject); + await ct.SaveChangesAsync(); + + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await LaborSearchIndexAsync(newObject, true); + // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await LaborHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await LaborPopulateVizFields(newObject); + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task LaborGetAsync(long id, bool logTheGetEvent = true) + { + + var ret = await ct.QuoteItemLabor.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (UserIsRestrictedType && ret.UserId != UserId) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return null; + } + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task LaborPutAsync(QuoteItemLabor putObject) + { + var dbObject = await LaborGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + + await LaborValidateAsync(putObject, dbObject); + if (HasErrors) return null; + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await LaborExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await LaborSearchIndexAsync(putObject, false); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); + await LaborHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await LaborPopulateVizFields(putObject); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task LaborDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await LaborGetAsync(id, false); + LaborValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemLabor.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + // await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await LaborHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + + ////////////////////////////////////////////// + //INDEXING + // + private async Task LaborSearchIndexAsync(QuoteItemLabor obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.ServiceDetails); + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task LaborGetSearchResultSummary(long id) + { + var obj = await LaborGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.ServiceDetails); + return SearchParams; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task LaborPopulateVizFields(QuoteItemLabor o, bool calculateTotalsOnly = false) + { + if (calculateTotalsOnly == false) + { + if (o.UserId != null) + o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); + } + ServiceRate Rate = null; + if (o.ServiceRateId != null) + { + Rate = await ct.ServiceRate.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.ServiceRateId); + o.ServiceRateViz = Rate.Name; + } + TaxCode Tax = null; + if (o.TaxCodeSaleId != null) + Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeSaleId); + if (Tax != null) + o.TaxCodeViz = Tax.Name; + + o.PriceViz = 0; + if (Rate != null) + { + o.CostViz = Rate.Cost; + o.ListPriceViz = Rate.Charge; + o.UnitOfMeasureViz = Rate.Unit; + o.PriceViz = Rate.Charge;//default price used if not manual or contract override + } + + //manual price overrides anything + if (o.PriceOverride != null) + o.PriceViz = (decimal)o.PriceOverride; + else + { + //not manual so could potentially have a contract adjustment + var c = await GetCurrentQuoteContractFromRelatedAsync(AyaType.QuoteItem, o.QuoteItemId); + if (c != null) + { + decimal pct = 0; + ContractOverrideType cot = ContractOverrideType.PriceDiscount; + + bool TaggedAdjustmentInEffect = false; + + //POTENTIAL CONTRACT ADJUSTMENTS + //First check if there is a matching tagged service rate contract discount, that takes precedence + if (c.ContractServiceRateOverrideItems.Count > 0) + { + //Iterate all contract tagged items in order of ones with the most tags first + foreach (var csr in c.ContractServiceRateOverrideItems.OrderByDescending(z => z.Tags.Count)) + if (csr.Tags.All(z => Rate.Tags.Any(x => x == z))) + { + if (csr.OverridePct != 0) + { + pct = csr.OverridePct / 100; + cot = csr.OverrideType; + TaggedAdjustmentInEffect = true; + } + } + } + + //Generic discount? + if (!TaggedAdjustmentInEffect && c.ServiceRatesOverridePct != 0) + { + pct = c.ServiceRatesOverridePct / 100; + cot = c.ServiceRatesOverrideType; + } + + //apply if discount found + if (pct != 0) + { + if (cot == ContractOverrideType.CostMarkup) + o.PriceViz = o.CostViz + (o.CostViz * pct); + else if (cot == ContractOverrideType.PriceDiscount) + o.PriceViz = o.ListPriceViz - (o.ListPriceViz * pct); + } + } + } + + //Calculate totals and taxes + //NET + o.NetViz = o.PriceViz * o.ServiceRateQuantity; + + //TAX + o.TaxAViz = 0; + o.TaxBViz = 0; + if (Tax != null) + { + if (Tax.TaxAPct != 0) + { + o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); + } + if (Tax.TaxBPct != 0) + { + if (Tax.TaxOnTax) + { + o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); + } + else + { + o.TaxBViz = o.NetViz * (Tax.TaxBPct / 100); + } + } + } + o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; + + + //RESTRICTIONS ON COST VISIBILITY? + if (!UserCanViewLaborOrTravelRateCosts) + { + o.CostViz = 0; + } + + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task LaborValidateAsync(QuoteItemLabor proposedObj, QuoteItemLabor currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (UserIsRestrictedType && (proposedObj.UserId != UserId || (!isNew && currentObj.UserId != UserId))) + { + //no edits allowed on other people's records + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + + + //Start date AND end date must both be null or both contain values + if (proposedObj.ServiceStartDate == null && proposedObj.ServiceStopDate != null) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "ServiceStartDate"); + + if (proposedObj.ServiceStartDate != null && proposedObj.ServiceStopDate == null) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "ServiceStopDate"); + + //Start date before end date + if (proposedObj.ServiceStartDate != null && proposedObj.ServiceStopDate != null) + if (proposedObj.ServiceStartDate > proposedObj.ServiceStopDate) + AddError(ApiErrorCode.VALIDATION_STARTDATE_AFTER_ENDDATE, "ServiceStartDate"); + + if (proposedObj.ServiceRateQuantity < 0)//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "ServiceRateQuantity"); + + if (proposedObj.NoChargeQuantity < 0)//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "NoChargeQuantity"); + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemLabor.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + //CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void LaborValidateCanDelete(QuoteItemLabor obj) + { + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + if (UserIsRestrictedType) + { + //Labors: add (no user selection defaults to themselves), remove, view and edit only when they are the selected User + if (obj.UserId != UserId) + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemLabor)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task LaborHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemLabor oProposed = (QuoteItemLabor)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + //for notification purposes because has no name or tags field itself + oProposed.Name = WorkorderInfo.Serial.ToString(); + oProposed.Tags = WorkorderInfo.Tags; + + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + }//end of process notifications + + #endregion work order item LABOR level + + + /* + ██╗ ██████╗ █████╗ ███╗ ██╗ + ██║ ██╔═══██╗██╔══██╗████╗ ██║ + ██║ ██║ ██║███████║██╔██╗ ██║ + ██║ ██║ ██║██╔══██║██║╚██╗██║ + ███████╗╚██████╔╝██║ ██║██║ ╚████║ + */ + + #region QuoteItemLoan level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task LoanExistsAsync(long id) + { + return await ct.QuoteItemLoan.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task LoanCreateAsync(QuoteItemLoan newObject) + { + await LoanValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await LoanBizActionsAsync(AyaEvent.Created, newObject, null, null); + await ct.QuoteItemLoan.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await LoanSearchIndexAsync(newObject, true); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await LoanHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await LoanPopulateVizFields(newObject); + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task LoanGetAsync(long id, bool logTheGetEvent = true) + { + if (UserIsSubContractorRestricted) //no access allowed at all + return null; + + var ret = await ct.QuoteItemLoan.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task LoanPutAsync(QuoteItemLoan putObject) + { + var dbObject = await LoanGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + await LoanValidateAsync(putObject, dbObject); + if (HasErrors) return null; + await LoanBizActionsAsync(AyaEvent.Modified, putObject, dbObject, null); + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await LoanExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await LoanSearchIndexAsync(putObject, false); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); + await LoanHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await LoanPopulateVizFields(putObject); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task LoanDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await LoanGetAsync(id, false); + LoanValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemLoan.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + //await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await LoanHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + ////////////////////////////////////////////// + //INDEXING + // + private async Task LoanSearchIndexAsync(QuoteItemLoan obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.Notes); + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task LoanGetSearchResultSummary(long id) + { + var obj = await LoanGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.Notes); + return SearchParams; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task LoanPopulateVizFields(QuoteItemLoan o, List loanUnitRateEnumList = null, bool calculateTotalsOnly = false) + { + if (calculateTotalsOnly == false) + { + if (loanUnitRateEnumList == null) + loanUnitRateEnumList = await AyaNova.Api.Controllers.EnumListController.GetEnumList( + StringUtil.TrimTypeName(typeof(LoanUnitRateUnit).ToString()), + UserTranslationId, + CurrentUserRoles); + o.UnitOfMeasureViz = loanUnitRateEnumList.Where(x => x.Id == (long)o.Rate).Select(x => x.Name).First(); + } + + LoanUnit loanUnit = await ct.LoanUnit.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.LoanUnitId); + o.LoanUnitViz = loanUnit.Name; + + TaxCode Tax = null; + if (o.TaxCodeId != null) + Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeId); + if (Tax != null) + o.TaxCodeViz = Tax.Name; + + + //manual price overrides anything + o.PriceViz = o.ListPrice; + if (o.PriceOverride != null) + o.PriceViz = (decimal)o.PriceOverride; + //Currently not contract discounted so no further calcs need apply to priceViz + + //Calculate totals and taxes + //NET + o.NetViz = o.PriceViz * o.Quantity; + + //TAX + o.TaxAViz = 0; + o.TaxBViz = 0; + if (Tax != null) + { + if (Tax.TaxAPct != 0) + { + o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); + } + if (Tax.TaxBPct != 0) + { + if (Tax.TaxOnTax) + { + o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); + } + else + { + o.TaxBViz = o.NetViz * (Tax.TaxBPct / 100); + } + } + } + o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; + + //RESTRICTED COST FIELD?? + if (!UserCanViewLoanerCosts) + o.Cost = 0;//cost already used in calcs and will not be updated on any update operation so this ensures the cost isn't sent over the wire + + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //BIZ ACTIONS + // + // + private async Task LoanBizActionsAsync(AyaEvent ayaEvent, QuoteItemLoan newObj, QuoteItemLoan oldObj, IDbContextTransaction transaction) + { + //automatic actions on record change, called AFTER validation + + //currently no processing required except for created or modified at this time + if (ayaEvent != AyaEvent.Created && ayaEvent != AyaEvent.Modified) + return; + + //SNAPSHOT PRICING + bool SnapshotPricing = true; + + //if modifed, see what has changed and should be re-applied + if (ayaEvent == AyaEvent.Modified) + { + //If it wasn't a complete part change there is no need to set pricing + if (newObj.LoanUnitId == oldObj.LoanUnitId && newObj.Rate == oldObj.Rate) + { + SnapshotPricing = false; + //maintain old cost as it can come from the client as zero when it shouldn't be or someone using the api and setting it directly + //but we will only allow the price *we* set at the server initially + newObj.Cost = oldObj.Cost; + } + } + + //Pricing + if (SnapshotPricing) + { + //default in case nothing to apply + newObj.Cost = 0; + newObj.ListPrice = 0; + + LoanUnit loanUnit = await ct.LoanUnit.AsNoTracking().FirstOrDefaultAsync(x => x.Id == newObj.LoanUnitId); + if (loanUnit != null) + { + switch (newObj.Rate) + { + case LoanUnitRateUnit.None: + break; + case LoanUnitRateUnit.Hours: + newObj.Cost = loanUnit.RateHourCost; + newObj.ListPrice = loanUnit.RateHour; + break; + + case LoanUnitRateUnit.HalfDays: + newObj.Cost = loanUnit.RateHalfDayCost; + newObj.ListPrice = loanUnit.RateHalfDay; + break; + case LoanUnitRateUnit.Days: + newObj.Cost = loanUnit.RateDayCost; + newObj.ListPrice = loanUnit.RateDay; + break; + case LoanUnitRateUnit.Weeks: + newObj.Cost = loanUnit.RateWeekCost; + newObj.ListPrice = loanUnit.RateWeek; + break; + case LoanUnitRateUnit.Months: + newObj.Cost = loanUnit.RateMonthCost; + newObj.ListPrice = loanUnit.RateMonth; + break; + case LoanUnitRateUnit.Years: + newObj.Cost = loanUnit.RateYearCost; + newObj.ListPrice = loanUnit.RateYear; + break; + + } + } + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task LoanValidateAsync(QuoteItemLoan proposedObj, QuoteItemLoan currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (UserIsRestrictedType) + { + //no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (proposedObj.LoanUnitId < 1 || !await ct.LoanUnit.AnyAsync(x => x.Id == proposedObj.LoanUnitId)) + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "LoanUnitId"); + + if (proposedObj.Quantity < 0)//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Quantity"); + + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemLoan.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + //CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void LoanValidateCanDelete(QuoteItemLoan obj) + { + if (UserIsRestrictedType) + { + //no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemLoan)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task LoanHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemLoan oProposed = (QuoteItemLoan)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + //for notification purposes because has no name / tags field itself + oProposed.Name = WorkorderInfo.Serial.ToString(); + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + }//end of process notifications + + + #endregion work order item LOAN level + + + + + /* + + + ██████╗ ██╗ ██╗████████╗███████╗██╗██████╗ ███████╗ ███████╗███████╗██████╗ ██╗ ██╗██╗ ██████╗███████╗ + ██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██║██╔══██╗██╔════╝ ██╔════╝██╔════╝██╔══██╗██║ ██║██║██╔════╝██╔════╝ + ██║ ██║██║ ██║ ██║ ███████╗██║██║ ██║█████╗ ███████╗█████╗ ██████╔╝██║ ██║██║██║ █████╗ + ██║ ██║██║ ██║ ██║ ╚════██║██║██║ ██║██╔══╝ ╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║██║ ██╔══╝ + ╚██████╔╝╚██████╔╝ ██║ ███████║██║██████╔╝███████╗ ███████║███████╗██║ ██║ ╚████╔╝ ██║╚██████╗███████╗ + ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═════╝ ╚══════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═════╝╚══════╝ + + + + */ + + #region QuoteItemOutsideService level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task OutsideServiceExistsAsync(long id) + { + return await ct.QuoteItemOutsideService.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task OutsideServiceCreateAsync(QuoteItemOutsideService newObject) + { + await OutsideServiceValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + // newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.QuoteItemOutsideService.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await OutsideServiceSearchIndexAsync(newObject, true); + // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await OutsideServicePopulateVizFields(newObject); + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task OutsideServiceGetAsync(long id, bool logTheGetEvent = true) + { + if (UserIsSubContractorRestricted || UserIsSubContractorFull) //no access allowed at all + return null; + var ret = await ct.QuoteItemOutsideService.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, AyaType.QuoteItemOutsideService, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task OutsideServicePutAsync(QuoteItemOutsideService putObject) + { + QuoteItemOutsideService dbObject = await OutsideServiceGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + + // dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); + // dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); + + await OutsideServiceValidateAsync(putObject, dbObject); + if (HasErrors) return null; + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await OutsideServiceExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await OutsideServiceSearchIndexAsync(putObject, false); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); + + await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await OutsideServicePopulateVizFields(putObject); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task OutsideServiceDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await OutsideServiceGetAsync(id, false); + OutsideServiceValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemOutsideService.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await OutsideServiceHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + ////////////////////////////////////////////// + //INDEXING + // + private async Task OutsideServiceSearchIndexAsync(QuoteItemOutsideService obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.Notes).AddText(obj.RMANumber).AddText(obj.TrackingNumber); + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task OutsideServiceGetSearchResultSummary(long id) + { + var obj = await OutsideServiceGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.Notes).AddText(obj.RMANumber).AddText(obj.TrackingNumber); + return SearchParams; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task OutsideServicePopulateVizFields(QuoteItemOutsideService o, bool calculateTotalsOnly = false) + { + if (calculateTotalsOnly == false) + { + if (o.UnitId != 0) + o.UnitViz = await ct.Unit.AsNoTracking().Where(x => x.Id == o.UnitId).Select(x => x.Serial).FirstOrDefaultAsync(); + if (o.VendorSentToId != null) + o.VendorSentToViz = await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorSentToId).Select(x => x.Name).FirstOrDefaultAsync(); + if (o.VendorSentViaId != null) + o.VendorSentViaViz = await ct.Vendor.AsNoTracking().Where(x => x.Id == o.VendorSentViaId).Select(x => x.Name).FirstOrDefaultAsync(); + } + + TaxCode Tax = null; + if (o.TaxCodeId != null) + Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeId); + if (Tax != null) + o.TaxCodeViz = Tax.Name; + + + o.CostViz = o.ShippingCost + o.RepairCost; + o.PriceViz = o.ShippingPrice + o.RepairPrice; + + //Currently not contract discounted so no further calcs need apply to priceViz + + //Calculate totals and taxes + //NET + o.NetViz = o.PriceViz;//just for standardization, no quantity so is redundant but reporting easier if all the same + + //TAX + o.TaxAViz = 0; + o.TaxBViz = 0; + if (Tax != null) + { + if (Tax.TaxAPct != 0) + { + o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); + } + if (Tax.TaxBPct != 0) + { + if (Tax.TaxOnTax) + { + o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); + } + else + { + o.TaxBViz = o.NetViz * (Tax.TaxBPct / 100); + } + } + } + o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task OutsideServiceValidateAsync(QuoteItemOutsideService proposedObj, QuoteItemOutsideService currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (UserIsRestrictedType) + { + //no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (proposedObj.UnitId < 1 || !await ct.Unit.AnyAsync(x => x.Id == proposedObj.UnitId)) + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "UnitId"); + + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemOutsideService.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + //CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void OutsideServiceValidateCanDelete(QuoteItemOutsideService obj) + { + if (UserIsRestrictedType) + { + //no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemOutsideService)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task OutsideServiceHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + /* + OutsideServiceOverdue = 16,//* Workorder object , WorkorderItemOutsideService created / updated, sets advance notice on due date tag filterable + OutsideServiceReceived = 17,//* Workorder object , WorkorderItemOutsideService updated, instant notification when item received, tag filterable + */ + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + + QuoteItemOutsideService oProposed = (QuoteItemOutsideService)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + //for notification purposes because has no name / tags field itself + oProposed.Name = WorkorderInfo.Serial.ToString(); + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + //## DELETED EVENTS + //standard process above will remove any hanging around when deleted, nothing else specific here to deal with + + + + //## CREATED + if (ayaEvent == AyaEvent.Created) + { + //OutsideServiceOverdue + if (oProposed.ETADate != null) + { + //Conditions: tags + time delayed eta value + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceOverdue).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.OutsideServiceOverdue, + UserId = sub.UserId, + AyaType = AyaType.QuoteItemOutsideService, + ObjectId = oProposed.Id, + NotifySubscriptionId = sub.Id, + EventDate = (DateTime)oProposed.ETADate, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//OutsideServiceOverdue + + //OutsideServiceReceived (here because it's possible a outside service is entered new with both an eta and received date if entered after the fact) + if (oProposed.ReturnDate != null) + { + //Clear overdue ones as it's now received + await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.OutsideServiceOverdue); + + //Conditions: tags + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceReceived).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.OutsideServiceReceived, + UserId = sub.UserId, + AyaType = AyaType.QuoteItemOutsideService, + ObjectId = oProposed.Id, + NotifySubscriptionId = sub.Id, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//OutsideServiceReceived + } + + //## MODIFIED + if (ayaEvent == AyaEvent.Modified) + { + QuoteItemOutsideService oCurrent = (QuoteItemOutsideService)currentObj; + + //OutsideServiceOverdue + //if modified then remove any potential prior ones in case irrelevant + if (oProposed.ETADate != oCurrent.ETADate) + { + //eta changed, so first of all remove any prior ones + await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.OutsideServiceOverdue); + //now can go ahead and add back again as appropriate + if (oProposed.ETADate != null) + { + //Conditions: tags + time delayed eta value + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceOverdue).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.OutsideServiceOverdue, + UserId = sub.UserId, + AyaType = AyaType.QuoteItemOutsideService, + ObjectId = oProposed.Id, + NotifySubscriptionId = sub.Id, + EventDate = (DateTime)oProposed.ETADate, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//OutsideServiceOverdue + } + + //OutsideServiceReceived + if (oProposed.ReturnDate != oCurrent.ReturnDate && oProposed.ReturnDate != null)//note that this is an instant notification type so no need to clear older ones like above which is time delayed + { + //Clear overdue ones as it's now received + await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.OutsideServiceOverdue); + + //Conditions: tags + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.OutsideServiceReceived).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.OutsideServiceReceived, + UserId = sub.UserId, + AyaType = AyaType.QuoteItemOutsideService, + ObjectId = oProposed.Id, + NotifySubscriptionId = sub.Id, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//OutsideServiceReceived + } + + }//end of process notifications + + + #endregion work order item OUTSIDE SERVICE level + + + + /* + ██████╗ █████╗ ██████╗ ████████╗███████╗ + ██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝ + ██████╔╝███████║██████╔╝ ██║ ███████╗ + ██╔═══╝ ██╔══██║██╔══██╗ ██║ ╚════██║ + ██║ ██║ ██║██║ ██║ ██║ ███████║ + ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ + */ + + #region QuoteItemPart level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task PartExistsAsync(long id) + { + return await ct.QuoteItemPart.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task CreatePartAsync(QuoteItemPart newObject) + { + using (var transaction = await ct.Database.BeginTransactionAsync()) + { + await PartValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await PartBizActionsAsync(AyaEvent.Created, newObject, null, null); + //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.QuoteItemPart.AddAsync(newObject); + await ct.SaveChangesAsync(); + await PartInventoryAdjustmentAsync(AyaEvent.Created, newObject, null, transaction); + if (HasErrors) + { + await transaction.RollbackAsync(); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await PartSearchIndexAsync(newObject, true); + await transaction.CommitAsync(); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await PartPopulateVizFields(newObject); + return newObject; + } + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task PartGetAsync(long id, bool logTheGetEvent = true) + { + if (UserIsSubContractorRestricted) //no access allowed at all + return null; + + var ret = await ct.QuoteItemPart.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task PartPutAsync(QuoteItemPart putObject) + { + using (var transaction = await ct.Database.BeginTransactionAsync()) + { + QuoteItemPart dbObject = await PartGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + //dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); + //dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); + + await PartValidateAsync(putObject, dbObject); + if (HasErrors) return null; + await PartBizActionsAsync(AyaEvent.Modified, putObject, dbObject, null); + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + await PartInventoryAdjustmentAsync(AyaEvent.Modified, putObject, dbObject, transaction); + if (HasErrors) + { + await transaction.RollbackAsync(); + return null; + } + } + catch (DbUpdateConcurrencyException) + { + if (!await PartExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, putObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await PartSearchIndexAsync(putObject, false); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); + await PartHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await transaction.CommitAsync(); + await PartPopulateVizFields(putObject); + return putObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task PartDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + + try + { + var dbObject = await PartGetAsync(id, false); + PartValidateCanDelete(dbObject); + if (HasErrors) + return false; + await PartBizActionsAsync(AyaEvent.Deleted, null, dbObject, transaction); + ct.QuoteItemPart.Remove(dbObject); + await ct.SaveChangesAsync(); + await PartInventoryAdjustmentAsync(AyaEvent.Deleted, null, dbObject, transaction); + if (HasErrors) + { + await transaction.RollbackAsync(); + return false; + } + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await PartHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + + ////////////////////////////////////////////// + //INDEXING + // + private async Task PartSearchIndexAsync(QuoteItemPart obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.Description).AddText(obj.Serials); + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task PartGetSearchResultSummary(long id) + { + var obj = await PartGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.Description).AddText(obj.Serials); + return SearchParams; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task PartPopulateVizFields(QuoteItemPart o, bool calculateTotalsOnly = false) + { + if (calculateTotalsOnly == false) + { + if (o.PartWarehouseId != 0) + o.PartWarehouseViz = await ct.PartWarehouse.AsNoTracking().Where(x => x.Id == o.PartWarehouseId).Select(x => x.Name).FirstOrDefaultAsync(); + } + Part part = null; + if (o.PartId != 0) + part = await ct.Part.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.PartId); + else + return;//this should never happen but this is insurance in case it does + + o.PartViz = part.PartNumber; + o.PartNameViz = part.Name; + o.UpcViz = part.UPC; + + TaxCode Tax = null; + if (o.TaxPartSaleId != null) + Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxPartSaleId); + if (Tax != null) + o.TaxCodeViz = Tax.Name; + + o.PriceViz = 0; + if (part != null) + { + //COST & PRICE NOT SET HERE, SET IN BIZACTIONS SNAPSHOTTED + // o.CostViz = part.Cost; + // o.ListPriceViz = part.Retail; + o.UnitOfMeasureViz = part.UnitOfMeasure; + o.PriceViz = o.ListPrice;//default price used if not manual or contract override + } + + //manual price overrides anything + if (o.PriceOverride != null) + o.PriceViz = (decimal)o.PriceOverride; + else + { + //not manual so could potentially have a contract adjustment + var c = await GetCurrentQuoteContractFromRelatedAsync(AyaType.QuoteItem, o.QuoteItemId); + if (c != null) + { + decimal pct = 0; + ContractOverrideType cot = ContractOverrideType.PriceDiscount; + + bool TaggedAdjustmentInEffect = false; + + //POTENTIAL CONTRACT ADJUSTMENTS + //First check if there is a matching tagged contract discount, that takes precedence + if (c.ContractPartOverrideItems.Count > 0) + { + //Iterate all contract tagged items in order of ones with the most tags first + foreach (var cp in c.ContractPartOverrideItems.OrderByDescending(z => z.Tags.Count)) + if (cp.Tags.All(z => part.Tags.Any(x => x == z))) + { + if (cp.OverridePct != 0) + { + pct = cp.OverridePct / 100; + cot = cp.OverrideType; + TaggedAdjustmentInEffect = true; + } + } + } + + //Generic discount? + if (!TaggedAdjustmentInEffect && c.ServiceRatesOverridePct != 0) + { + pct = c.ServiceRatesOverridePct / 100; + cot = c.ServiceRatesOverrideType; + } + + //apply if discount found + if (pct != 0) + { + if (cot == ContractOverrideType.CostMarkup) + o.PriceViz = o.Cost + (o.Cost * pct); + else if (cot == ContractOverrideType.PriceDiscount) + o.PriceViz = o.ListPrice - (o.ListPrice * pct); + } + } + } + + //Calculate totals and taxes + //NET + o.NetViz = o.PriceViz * o.Quantity; + + //TAX + o.TaxAViz = 0; + o.TaxBViz = 0; + if (Tax != null) + { + if (Tax.TaxAPct != 0) + { + o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); + } + if (Tax.TaxBPct != 0) + { + if (Tax.TaxOnTax) + { + o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); + } + else + { + o.TaxBViz = o.NetViz * (Tax.TaxBPct / 100); + } + } + } + o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; + + + //RESTRICTED COST FIELD?? + if (!UserCanViewPartCosts) + o.Cost = 0;//cost already used in calcs and will not be updated on any update operation so this ensures the cost isn't sent over the wire + + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //BIZ ACTIONS + // + // + private async Task PartBizActionsAsync(AyaEvent ayaEvent, QuoteItemPart newObj, QuoteItemPart oldObj, IDbContextTransaction transaction) + { + + + //SNAPSHOT PRICING IF NECESSARY + if (ayaEvent != AyaEvent.Created && ayaEvent != AyaEvent.Modified) + return; + + //SNAPSHOT PRICING + bool SnapshotPricing = true; + + //if modifed, see what has changed and should be re-applied + if (ayaEvent == AyaEvent.Modified) + { + //If it wasn't a complete part change there is no need to set pricing + if (newObj.PartId == oldObj.PartId) + { + SnapshotPricing = false; + //maintain old cost as it can come from the client as zero when it shouldn't be or someone using the api and setting it directly + //but we will only allow the price *we* set at the server initially + newObj.Cost = oldObj.Cost; + + } + } + + + //Pricing + if (SnapshotPricing) + { + //default in case nothing to apply + newObj.Cost = 0; + newObj.ListPrice = 0; + + + var s = await ct.Part.AsNoTracking().FirstOrDefaultAsync(z => z.Id == newObj.PartId); + if (s != null) + { + newObj.Cost = s.Cost; + newObj.ListPrice = s.Retail; + } + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //BIZ ACTIONS + // + // + private async Task PartInventoryAdjustmentAsync(AyaEvent ayaEvent, QuoteItemPart newObj, QuoteItemPart oldObj, IDbContextTransaction transaction) + { + + + if (AyaNova.Util.ServerGlobalBizSettings.Cache.UseInventory) + { + PartInventoryBiz pib = new PartInventoryBiz(ct, UserId, UserTranslationId, CurrentUserRoles); + + //DELETED, HANDLE INVENTORY / RETURN SERIALS + if (ayaEvent == AyaEvent.Deleted && oldObj.Quantity != 0) + { + dtInternalPartInventory pi = + new dtInternalPartInventory + { + PartId = oldObj.PartId, + PartWarehouseId = oldObj.PartWarehouseId, + Quantity = oldObj.Quantity, + SourceType = null,//null because the po no longer exists so this is technically a manual adjustment + SourceId = null,//'' + Description = await Translate("QuoteItemPart") + $" {oldObj.Serials} " + await Translate("EventDeleted") + }; + if (await pib.CreateAsync(pi) == null) + { + AddError(ApiErrorCode.API_SERVER_ERROR, "generalerror", $"Error updating inventory ({pi.Description}):{pib.GetErrorsAsString()}"); + return; + } + else + { //return serial numbers to part + if (!string.IsNullOrWhiteSpace(oldObj.Serials)) + await PartBiz.AppendSerialsAsync(oldObj.PartId, oldObj.Serials, ct, UserId); + } + } + + + //CREATED, HANDLE INVENTORY / CONSUME SERIALS + if (ayaEvent == AyaEvent.Created && newObj.Quantity != 0)//allow zero quantity parts on quote as placeholder, serials will not be consumed + { + dtInternalPartInventory pi = + new dtInternalPartInventory + { + PartId = newObj.PartId, + PartWarehouseId = newObj.PartWarehouseId, + Quantity = newObj.Quantity * -1, + SourceType = AyaType.QuoteItemPart, + SourceId = newObj.Id, + Description = await Translate("QuoteItemPart") + $" {newObj.Serials} " + await Translate("EventCreated") + }; + if (await pib.CreateAsync(pi) == null) + { + if (pib.HasErrors) + { + foreach (var e in pib.Errors) + { + if (e.Code == ApiErrorCode.INSUFFICIENT_INVENTORY) + AddError(e.Code, "Quantity", e.Message); + else + AddError(e.Code, e.Target, e.Message); + } + } + //AddError(ApiErrorCode.API_SERVER_ERROR, "generalerror", $"Error updating inventory ({pi.Description}):{pib.GetErrorsAsString()}"); + return; + } + else + { //Consume serial numbers from part + if (!string.IsNullOrWhiteSpace(newObj.Serials)) + await PartBiz.RemoveSerialsAsync(newObj.PartId, newObj.Serials, ct, UserId); + } + } + + + //UPDATED, HANDLE INVENTORY / UPDATE SERIALS AS REQUIRED + if (ayaEvent == AyaEvent.Modified) + { + //INVENTORY + if (newObj.PartId != oldObj.PartId || newObj.Quantity != oldObj.Quantity) + { + //OUT with the old + if (oldObj.Quantity != 0)//zero quantity doesn't affect inventory or serials + { + dtInternalPartInventory piOld = new dtInternalPartInventory + { + PartId = oldObj.PartId, + PartWarehouseId = oldObj.PartWarehouseId, + Quantity = oldObj.Quantity, + SourceType = null,//null because the po no longer exists so this is technically a manual adjustment + SourceId = null,//'' + Description = await Translate("QuoteItemPart") + $" {oldObj.Serials} " + await Translate("EventDeleted") + }; + if (await pib.CreateAsync(piOld) == null) + { + AddError(ApiErrorCode.API_SERVER_ERROR, "generalerror", $"Error updating inventory ({piOld.Description}):{pib.GetErrorsAsString()}"); + return; + } + else + { //return serial numbers to part + if (!string.IsNullOrWhiteSpace(oldObj.Serials)) + await PartBiz.AppendSerialsAsync(oldObj.PartId, oldObj.Serials, ct, UserId); + } + } + + + //IN with the new + if (newObj.Quantity != 0) + {//NOTE: zero quantity is considered to be a placeholder and no serials will be consumed, nor inventory affected + dtInternalPartInventory piNew = new dtInternalPartInventory + { + PartId = newObj.PartId, + PartWarehouseId = newObj.PartWarehouseId, + Quantity = newObj.Quantity * -1, + SourceType = AyaType.QuoteItemPart, + SourceId = newObj.Id, + Description = await Translate("QuoteItemPart") + $" {newObj.Serials} " + await Translate("EventCreated") + }; + + if (await pib.CreateAsync(piNew) == null) + { + AddError(ApiErrorCode.API_SERVER_ERROR, "generalerror", $"Error updating inventory ({piNew.Description}):{pib.GetErrorsAsString()}"); + return; + } + else + { //Consume serial numbers from part + if (!string.IsNullOrWhiteSpace(newObj.Serials)) + await PartBiz.RemoveSerialsAsync(newObj.PartId, newObj.Serials, ct, UserId); + } + } + } + //SERIALS + else if (newObj.Serials != oldObj.Serials) + { + //NOTE: zero quantity is considered to be a placeholder and no serials will be consumed (hence not returned either) + + //return serial numbers to part + if (oldObj.Quantity != 0 && !string.IsNullOrWhiteSpace(oldObj.Serials)) + await PartBiz.AppendSerialsAsync(oldObj.PartId, oldObj.Serials, ct, UserId); + + //Consume serial numbers from part + if (newObj.Quantity != 0 && !string.IsNullOrWhiteSpace(newObj.Serials)) + await PartBiz.RemoveSerialsAsync(newObj.PartId, newObj.Serials, ct, UserId); + } + } + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task PartValidateAsync(QuoteItemPart proposedObj, QuoteItemPart currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (UserIsRestrictedType) + { + //Parts: no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (!await BizObjectExistsInDatabase.ExistsAsync(AyaType.Part, proposedObj.PartId, ct)) + { + AddError(ApiErrorCode.NOT_FOUND, "PartId"); + return; + } + + if (proposedObj.Quantity < 0)//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "Quantity"); + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemPart.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + //CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void PartValidateCanDelete(QuoteItemPart obj) + { + if (UserIsRestrictedType) + { + //Parts: no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemPart)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task PartHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemPart oProposed = (QuoteItemPart)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name / tags field itself + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + }//end of process notifications + + + + + + #endregion work order item PARTS level + + + + /* + ███████╗ ██████╗██╗ ██╗███████╗██████╗ ██╗ ██╗██╗ ███████╗██████╗ ██╗ ██╗███████╗███████╗██████╗ ███████╗ + ██╔════╝██╔════╝██║ ██║██╔════╝██╔══██╗██║ ██║██║ ██╔════╝██╔══██╗ ██║ ██║██╔════╝██╔════╝██╔══██╗██╔════╝ + ███████╗██║ ███████║█████╗ ██║ ██║██║ ██║██║ █████╗ ██║ ██║█████╗██║ ██║███████╗█████╗ ██████╔╝███████╗ + ╚════██║██║ ██╔══██║██╔══╝ ██║ ██║██║ ██║██║ ██╔══╝ ██║ ██║╚════╝██║ ██║╚════██║██╔══╝ ██╔══██╗╚════██║ + ███████║╚██████╗██║ ██║███████╗██████╔╝╚██████╔╝███████╗███████╗██████╔╝ ╚██████╔╝███████║███████╗██║ ██║███████║ + ╚══════╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝ + */ + + #region QuoteItemScheduledUser level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task ScheduledUserExistsAsync(long id) + { + return await ct.QuoteItemScheduledUser.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task ScheduledUserCreateAsync(QuoteItemScheduledUser newObject) + { + await ScheduledUserValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.QuoteItemScheduledUser.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + //await ScheduledUserSearchIndexAsync(newObject, true); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await ScheduledUserPopulateVizFields(newObject); + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task ScheduledUserGetAsync(long id, bool logTheGetEvent = true) + { + var ret = await ct.QuoteItemScheduledUser.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (UserIsRestrictedType && ret.UserId != UserId)//restricted users can only see their own + return null; + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task ScheduledUserPutAsync(QuoteItemScheduledUser putObject) + { + QuoteItemScheduledUser dbObject = await ScheduledUserGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + //dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); + // dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); + await ScheduledUserValidateAsync(putObject, dbObject); + if (HasErrors) return null; + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await ScheduledUserExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + // await ScheduledUserSearchIndexAsync(dbObject, false); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); + await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await ScheduledUserPopulateVizFields(putObject); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task ScheduledUserDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await ScheduledUserGetAsync(id, false); + ScheduledUserValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemScheduledUser.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await ScheduledUserHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task ScheduledUserPopulateVizFields(QuoteItemScheduledUser o) + { + if (o.UserId != null) + o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); + if (o.ServiceRateId != null) + o.ServiceRateViz = await ct.ServiceRate.AsNoTracking().Where(x => x.Id == o.ServiceRateId).Select(x => x.Name).FirstOrDefaultAsync(); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task ScheduledUserValidateAsync(QuoteItemScheduledUser proposedObj, QuoteItemScheduledUser currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + + if (UserIsRestrictedType) + { + //Scheduled Users: view only where they are the selected User and convert to labor record + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (proposedObj.EstimatedQuantity < 0)//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "EstimatedQuantity"); + + + //Start date AND end date must both be null or both contain values + if (proposedObj.StartDate == null && proposedObj.StopDate != null) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "StopDate"); + + if (proposedObj.StartDate != null && proposedObj.StopDate == null) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "StopDate"); + + //Start date before end date + if (proposedObj.StartDate != null && proposedObj.StopDate != null) + if (proposedObj.StartDate > proposedObj.StopDate) + AddError(ApiErrorCode.VALIDATION_STARTDATE_AFTER_ENDDATE, "StartDate"); + + //Scheduling conflict? + if (!AyaNova.Util.ServerGlobalBizSettings.Cache.AllowScheduleConflicts + && proposedObj.UserId != null + && proposedObj.StartDate != null + && proposedObj.StopDate != null + && (isNew + || (proposedObj.StartDate != currentObj.StartDate) + || (proposedObj.StopDate != currentObj.StopDate) + || (proposedObj.UserId != currentObj.UserId) + )) + { + if (await ct.QuoteItemScheduledUser.AnyAsync(x => x.Id != proposedObj.Id + && x.UserId == proposedObj.UserId + && x.StartDate <= proposedObj.StopDate + && proposedObj.StartDate <= x.StopDate)) + { + AddError(ApiErrorCode.VALIDATION_FAILED, "StartDate", await Translate("ScheduleConflict")); + AddError(ApiErrorCode.VALIDATION_FAILED, "StopDate", await Translate("ScheduleConflict")); + } + + } + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemScheduledUser.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + //CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void ScheduledUserValidateCanDelete(QuoteItemScheduledUser obj) + { + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + if (UserIsRestrictedType) + { + //Scheduled Users: view only where they are the selected User and convert to labor record + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemScheduledUser)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task ScheduledUserHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemScheduledUser oProposed = (QuoteItemScheduledUser)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name field itself + oProposed.Tags = WorkorderInfo.Tags; //for notification purposes because has no tag field itself + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + //## CREATED / UPDATED - ScheduledOnWorkorder event + //Note: scheduled on quote is immediate so same process regardless if modified or updated + //because modified changes nearly all affect user so decision is just send it no matter what as any difference is enough to send + if (ayaEvent == AyaEvent.Created || ayaEvent == AyaEvent.Modified) + { + //this block is entirely about //ScheduledOnWorkorder event + + if (oProposed.UserId != null) + { + //Conditions: userid match and tags + //delivery is immediate so no need to remove old ones of this kind + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.ScheduledOnWorkorder && z.UserId == oProposed.UserId).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.ScheduledOnWorkorder, + UserId = sub.UserId, + AyaType = AyaType.QuoteItemScheduledUser, + ObjectId = oProposed.Id, + NotifySubscriptionId = sub.Id, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//ScheduledOnWorkorder + } + + //--------------------------------------------------------------------------------------------------------------------------------------------- + + //## CREATED + if (ayaEvent == AyaEvent.Created) + { + //ScheduledOnWorkorderImminent + if (oProposed.UserId != null && oProposed.StartDate != null) + { + //Conditions: userid match and tags + time delayed age value + //delivery is delayed so need to remove old ones of this kind on update + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.ScheduledOnWorkorderImminent && z.UserId == oProposed.UserId).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.ScheduledOnWorkorderImminent, + UserId = sub.UserId, + AyaType = AyaType.QuoteItemScheduledUser, + ObjectId = oProposed.Id, + NotifySubscriptionId = sub.Id, + EventDate = (DateTime)oProposed.StartDate, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//ScheduledOnWorkorderImminent + } + + + + //## MODIFIED + if (ayaEvent == AyaEvent.Modified) + { + + //ScheduledOnWorkorderImminent + //Always clear any old ones for this object as they are all irrelevant the moment changed: + await NotifyEventHelper.ClearPriorEventsForObject(ct, proposedObj.AyaType, proposedObj.Id, NotifyEventType.ScheduledOnWorkorderImminent); + + if (oProposed.UserId != null && oProposed.StartDate != null) + { + //Conditions: userid match and tags + time delayed age value + //delivery is delayed so need to remove old ones of this kind on update + var subs = await ct.NotifySubscription.AsNoTracking().Where(z => z.EventType == NotifyEventType.ScheduledOnWorkorderImminent && z.UserId == oProposed.UserId).ToListAsync(); + foreach (var sub in subs) + { + //not for inactive users + if (!await UserBiz.UserIsActive(sub.UserId)) continue; + + //Tag match? (will be true if no sub tags so always safe to call this) + if (NotifyEventHelper.ObjectHasAllSubscriptionTags(WorkorderInfo.Tags, sub.Tags)) + { + NotifyEvent n = new NotifyEvent() + { + EventType = NotifyEventType.ScheduledOnWorkorderImminent, + UserId = sub.UserId, + AyaType = AyaType.QuoteItemScheduledUser, + ObjectId = oProposed.Id, + NotifySubscriptionId = sub.Id, + EventDate = (DateTime)oProposed.StartDate, + Name = $"{WorkorderInfo.Serial.ToString()}" + }; + await ct.NotifyEvent.AddAsync(n); + log.LogDebug($"Adding NotifyEvent: [{n.ToString()}]"); + await ct.SaveChangesAsync(); + } + } + }//ScheduledOnWorkorderImminent + } + + }//end of process notifications + + + + #endregion work order item SCHEDULED USER level + + + /* + ████████╗ █████╗ ███████╗██╗ ██╗ + ╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ + ██║ ███████║███████╗█████╔╝ + ██║ ██╔══██║╚════██║██╔═██╗ + ██║ ██║ ██║███████║██║ ██╗ + ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ + */ + + #region QuoteItemTask level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task TaskExistsAsync(long id) + { + return await ct.QuoteItemTask.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task TaskCreateAsync(QuoteItemTask newObject) + { + await TaskValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + //newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + //newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.QuoteItemTask.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await TaskSearchIndexAsync(newObject, true); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await TaskHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await TaskPopulateVizFields(newObject); + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task TaskGetAsync(long id, bool logTheGetEvent = true) + { + var ret = await ct.QuoteItemTask.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task TaskPutAsync(QuoteItemTask putObject) + { + QuoteItemTask dbObject = await TaskGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + //dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); + //dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); + await TaskValidateAsync(putObject, dbObject); + if (HasErrors) return null; + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await TaskExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await TaskSearchIndexAsync(dbObject, false); + // await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); + await TaskHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await TaskPopulateVizFields(putObject); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task TaskDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await TaskGetAsync(id, false); + TaskValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemTask.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + //await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + //await FileUtil.DeleteAttachmentsForObjectAsync(dbObject.AyaType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await TaskHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + ////////////////////////////////////////////// + //INDEXING + // + private async Task TaskSearchIndexAsync(QuoteItemTask obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.Task);//some are manually entered so this is worthwhile for that at least, also I guess predefined tasks that are more rare + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task TaskGetSearchResultSummary(long id) + { + var obj = await TaskGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.Task); + return SearchParams; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task TaskPopulateVizFields(QuoteItemTask o, List taskCompletionTypeEnumList = null) + { + if (o.CompletedByUserId != null) + o.CompletedByUserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.CompletedByUserId).Select(x => x.Name).FirstOrDefaultAsync(); + + if (taskCompletionTypeEnumList == null) + taskCompletionTypeEnumList = await AyaNova.Api.Controllers.EnumListController.GetEnumList( + StringUtil.TrimTypeName(typeof(WorkorderItemTaskCompletionType).ToString()), + UserTranslationId, + CurrentUserRoles); + + o.StatusViz = taskCompletionTypeEnumList.Where(x => x.Id == (long)o.Status).Select(x => x.Name).First(); + + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task TaskValidateAsync(QuoteItemTask proposedObj, QuoteItemTask currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + + + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (isNew && UserIsRestrictedType) + { + //restricted users are not allowed to make new task entries only fill them out + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (!isNew && UserIsRestrictedType) + { + //Existing record so just make sure they haven't changed the not changeable fields from the db version + + //* Tasks: view and edit existing tasks, set completion type and date only, no add or remove or changing other fields + //note that UI will prevent this, this rule is only backup for 3rd party api users + if (currentObj.Task != proposedObj.Task) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "Task"); + if (currentObj.CompletedByUserId != UserId) AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "CompletedByUserId"); + if (currentObj.Sequence != proposedObj.Sequence) AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "Sequence"); + } + + + if (string.IsNullOrWhiteSpace(proposedObj.Task)) + AddError(ApiErrorCode.VALIDATION_REQUIRED, "Task"); + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemTask.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + //CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void TaskValidateCanDelete(QuoteItemTask obj) + { + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + if (UserIsRestrictedType) + { + //restricted users are not allowed to delete a task only fill them out + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemTask)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task TaskHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemTask oProposed = (QuoteItemTask)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name / tags field itself + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + }//end of process notifications + + + #endregion work order item TASK level + + + /* + ████████╗██████╗ █████╗ ██╗ ██╗███████╗██╗ + ╚══██╔══╝██╔══██╗██╔══██╗██║ ██║██╔════╝██║ + ██║ ██████╔╝███████║██║ ██║█████╗ ██║ + ██║ ██╔══██╗██╔══██║╚██╗ ██╔╝██╔══╝ ██║ + ██║ ██║ ██║██║ ██║ ╚████╔╝ ███████╗███████╗ + ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚══════╝ + */ + + #region QuoteItemTravel level + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task TravelExistsAsync(long id) + { + return await ct.QuoteItemTravel.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task TravelCreateAsync(QuoteItemTravel newObject) + { + await TravelValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + await ct.QuoteItemTravel.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await TravelSearchIndexAsync(newObject, true); + await TravelHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await TravelPopulateVizFields(newObject); + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task TravelGetAsync(long id, bool logTheGetEvent = true) + { + var ret = await ct.QuoteItemTravel.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (UserIsRestrictedType && ret.UserId != UserId) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return null; + } + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task TravelPutAsync(QuoteItemTravel putObject) + { + QuoteItemTravel dbObject = await TravelGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + + await TravelValidateAsync(putObject, dbObject); + if (HasErrors) return null; + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await TravelExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await TravelSearchIndexAsync(putObject, false); + //await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, dbObject.Tags, SnapshotOfOriginalDBObj.Tags); + await TravelHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await TravelPopulateVizFields(putObject); + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task TravelDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await TravelGetAsync(id, false); + TravelValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemTravel.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct); + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + // await TagBiz.ProcessDeleteTagsInRepositoryAsync(ct, dbObject.Tags); + //await FileUtil.DeleteAttachmentsForObjectAsync(BizType, dbObject.Id, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await TravelHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + + ////////////////////////////////////////////// + //INDEXING + // + private async Task TravelSearchIndexAsync(QuoteItemTravel obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.TravelDetails); + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task TravelGetSearchResultSummary(long id) + { + var obj = await TravelGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.TravelDetails); + return SearchParams; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task TravelPopulateVizFields(QuoteItemTravel o, bool calculateTotalsOnly = false) + { + if (calculateTotalsOnly == false) + { + if (o.UserId != null) + o.UserViz = await ct.User.AsNoTracking().Where(x => x.Id == o.UserId).Select(x => x.Name).FirstOrDefaultAsync(); + } + TravelRate Rate = null; + if (o.TravelRateId != null) + { + Rate = await ct.TravelRate.AsNoTracking().FirstOrDefaultAsync(x => x.Id == o.TravelRateId); + o.TravelRateViz = Rate.Name; + } + TaxCode Tax = null; + if (o.TaxCodeSaleId != null) + Tax = await ct.TaxCode.AsNoTracking().FirstOrDefaultAsync(z => z.Id == o.TaxCodeSaleId); + if (Tax != null) + o.TaxCodeViz = Tax.Name; + + o.PriceViz = 0; + if (Rate != null) + { + o.CostViz = Rate.Cost; + o.ListPriceViz = Rate.Charge; + o.UnitOfMeasureViz = Rate.Unit; + o.PriceViz = Rate.Charge;//default price used if not manual or contract override + } + + //manual price overrides anything + if (o.PriceOverride != null) + o.PriceViz = (decimal)o.PriceOverride; + else + { + //not manual so could potentially have a contract adjustment + var c = await GetCurrentQuoteContractFromRelatedAsync(AyaType.QuoteItem, o.QuoteItemId); + if (c != null) + { + decimal pct = 0; + ContractOverrideType cot = ContractOverrideType.PriceDiscount; + + bool TaggedAdjustmentInEffect = false; + + //POTENTIAL CONTRACT ADJUSTMENTS + //First check if there is a matching tagged Travel rate contract discount, that takes precedence + if (c.ContractTravelRateOverrideItems.Count > 0) + { + //Iterate all contract tagged items in order of ones with the most tags first + foreach (var csr in c.ContractTravelRateOverrideItems.OrderByDescending(z => z.Tags.Count)) + if (csr.Tags.All(z => Rate.Tags.Any(x => x == z))) + { + if (csr.OverridePct != 0) + { + pct = csr.OverridePct / 100; + cot = csr.OverrideType; + TaggedAdjustmentInEffect = true; + } + } + } + + //Generic discount? + if (!TaggedAdjustmentInEffect && c.TravelRatesOverridePct != 0) + { + pct = c.TravelRatesOverridePct / 100; + cot = c.TravelRatesOverrideType; + } + + //apply if discount found + if (pct != 0) + { + if (cot == ContractOverrideType.CostMarkup) + o.PriceViz = o.CostViz + (o.CostViz * pct); + else if (cot == ContractOverrideType.PriceDiscount) + o.PriceViz = o.ListPriceViz - (o.ListPriceViz * pct); + } + } + } + + //Calculate totals and taxes + //NET + o.NetViz = o.PriceViz * o.TravelRateQuantity; + + //TAX + o.TaxAViz = 0; + o.TaxBViz = 0; + if (Tax != null) + { + if (Tax.TaxAPct != 0) + { + o.TaxAViz = o.NetViz * (Tax.TaxAPct / 100); + } + if (Tax.TaxBPct != 0) + { + if (Tax.TaxOnTax) + { + o.TaxBViz = (o.NetViz + o.TaxAViz) * (Tax.TaxBPct / 100); + } + else + { + o.TaxBViz = o.NetViz * (Tax.TaxBPct / 100); + } + } + } + o.LineTotalViz = o.NetViz + o.TaxAViz + o.TaxBViz; + + //RESTRICTIONS ON COST VISIBILITY? + if (!UserCanViewLaborOrTravelRateCosts) + { + o.CostViz = 0; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task TravelValidateAsync(QuoteItemTravel proposedObj, QuoteItemTravel currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + //run validation and biz rules + bool isNew = currentObj == null; + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (UserIsRestrictedType && (proposedObj.UserId != UserId || (!isNew && currentObj.UserId != UserId))) + { + //no edits allowed on other people's records + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + + if (proposedObj.TravelRateQuantity < 0)//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "TravelRateQuantity"); + + if (proposedObj.NoChargeQuantity < 0)//negative quantities are not allowed + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "NoChargeQuantity"); + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemTravel.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + //CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void TravelValidateCanDelete(QuoteItemTravel obj) + { + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + if (UserIsRestrictedType) + { + //Travels: add (no user selection defaults to themselves), remove, view and edit only when they are the selected User + if (obj.UserId != UserId) + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemTravel)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task TravelHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemTravel oProposed = (QuoteItemTravel)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + oProposed.Name = WorkorderInfo.Serial.ToString();//for notification purposes because has no name / tags field itself + oProposed.Tags = WorkorderInfo.Tags; + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + }//end of process notifications + + + #endregion work order item TRAVEL level + + + /* + ██╗ ██╗███╗ ██╗██╗████████╗ + ██║ ██║████╗ ██║██║╚══██╔══╝ + ██║ ██║██╔██╗ ██║██║ ██║ + ██║ ██║██║╚██╗██║██║ ██║ + ╚██████╔╝██║ ╚████║██║ ██║ + ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝ + */ + + #region QuoteItemUnit level + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //EXISTS + internal async Task UnitExistsAsync(long id) + { + return await ct.QuoteItemUnit.AnyAsync(z => z.Id == id); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + // + internal async Task UnitCreateAsync(QuoteItemUnit newObject) + { + //todo: contract stuff and validation of no other existing contracted unit + //assumptions: this create only gets called if there is an existing woheader saved in all cases + + + await UnitValidateAsync(newObject, null); + if (HasErrors) + return null; + else + { + //await UnitBizActionsAsync(AyaEvent.Created, newObject, null, null); + newObject.Tags = TagBiz.NormalizeTags(newObject.Tags); + newObject.CustomFields = JsonUtil.CompactJson(newObject.CustomFields); + await ct.QuoteItemUnit.AddAsync(newObject); + await ct.SaveChangesAsync(); + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, newObject.Id, newObject.AyaType, AyaEvent.Created), ct); + await UnitSearchIndexAsync(newObject, true); + await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, newObject.Tags, null); + await UnitHandlePotentialNotificationEvent(AyaEvent.Created, newObject); + await UnitPopulateVizFields(newObject, false); + + return newObject; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // GET + // + internal async Task UnitGetAsync(long id, bool logTheGetEvent = true) + { + if (UserIsSubContractorRestricted) //no access allowed at all + return null; + + var ret = await ct.QuoteItemUnit.AsNoTracking().SingleOrDefaultAsync(z => z.Id == id); + if (logTheGetEvent && ret != null) + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, id, ret.AyaType, AyaEvent.Retrieved), ct); + return ret; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + internal async Task UnitPutAsync(QuoteItemUnit putObject) + { + QuoteItemUnit dbObject = await UnitGetAsync(putObject.Id, false); + if (dbObject == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return null; + } + if (dbObject.Concurrency != putObject.Concurrency) + { + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + dbObject.Tags = TagBiz.NormalizeTags(dbObject.Tags); + dbObject.CustomFields = JsonUtil.CompactJson(dbObject.CustomFields); + await UnitValidateAsync(putObject, dbObject); + if (HasErrors) return null; + + + ct.Replace(dbObject, putObject); + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!await UnitExistsAsync(putObject.Id)) + AddError(ApiErrorCode.NOT_FOUND); + else + AddError(ApiErrorCode.CONCURRENCY_CONFLICT); + return null; + } + await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.Id, putObject.AyaType, AyaEvent.Modified), ct); + await UnitSearchIndexAsync(putObject, false); + await TagBiz.ProcessUpdateTagsInRepositoryAsync(ct, putObject.Tags, dbObject.Tags); + await UnitHandlePotentialNotificationEvent(AyaEvent.Modified, putObject, dbObject); + await UnitPopulateVizFields(putObject, false); + + + return putObject; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + internal async Task UnitDeleteAsync(long id, IDbContextTransaction parentTransaction = null) + { + var transaction = parentTransaction ?? await ct.Database.BeginTransactionAsync(); + try + { + var dbObject = await UnitGetAsync(id, false); + UnitValidateCanDelete(dbObject); + if (HasErrors) + return false; + ct.QuoteItemUnit.Remove(dbObject); + await ct.SaveChangesAsync(); + + //Log event + await EventLogProcessor.DeleteObjectLogAsync(UserId, dbObject.AyaType, dbObject.Id, "woitem:" + dbObject.QuoteItemId.ToString(), ct);//Fix?? + await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, dbObject.AyaType, ct); + if (parentTransaction == null) + await transaction.CommitAsync(); + await UnitHandlePotentialNotificationEvent(AyaEvent.Deleted, dbObject); + } + catch + { + //Just re-throw for now, let exception handler deal, but in future may want to deal with this more here + throw; + } + return true; + } + + + ////////////////////////////////////////////// + //INDEXING + // + private async Task UnitSearchIndexAsync(QuoteItemUnit obj, bool isNew) + { + //SEARCH INDEXING + var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationId, obj.Id, obj.AyaType); + SearchParams.AddText(obj.Notes).AddText(obj.Tags).AddCustomFields(obj.CustomFields); + + if (isNew) + await Search.ProcessNewObjectKeywordsAsync(SearchParams); + else + await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams); + } + + public async Task UnitGetSearchResultSummary(long id) + { + var obj = await UnitGetAsync(id, false); + var SearchParams = new Search.SearchIndexProcessObjectParameters(); + if (obj != null) + SearchParams.AddText(obj.Notes).AddText(obj.Tags).AddCustomFields(obj.CustomFields); + return SearchParams; + } + + + + + + // //////////////////////////////////////////////////////////////////////////////////////////////// + // //BIZ ACTIONS + // // + // // + // private async Task UnitBizActionsAsync(AyaEvent ayaEvent, QuoteItemUnit newObj, QuoteItemUnit oldObj, IDbContextTransaction transaction) + // { + // //automatic actions on record change, called AFTER validation + + // //currently no processing required except for created or modified at this time + // if (ayaEvent != AyaEvent.Created && ayaEvent != AyaEvent.Modified) + // return; + + + // if (newOrChangedActiveUnitContract != null && (ayaEvent == AyaEvent.Modified || ayaEvent == AyaEvent.Created))//note: keeping this qualification defensively in case more biz actions added later + // { + // //set contract if applicable + // //Note: validation has already set neworchangeactiveunitcontract and only sets it if it's applicable + // //so in here we just need to apply that contract to the header + // //I've decided that it will attempt to set the header here now rather than after the unit has set + // //as it's more important to have the unit record be saved than to + + + // // //If it wasn't a complete part change there is no need to set pricing + // // if (newObj.LoanUnitId == oldObj.LoanUnitId && newObj.Rate == oldObj.Rate) + // // { + // // SnapshotPricing = false; + // // //maintain old cost as it can come from the client as zero when it shouldn't be or someone using the api and setting it directly + // // //but we will only allow the price *we* set at the server initially + // // newObj.Cost = oldObj.Cost; + // // } + // } + // } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VIZ POPULATE + // + private async Task UnitPopulateVizFields(QuoteItemUnit o, bool populateForReporting) + { + var unitInfo = await ct.Unit.AsNoTracking() + .Where(x => x.Id == o.UnitId) + .Select(x => new { x.Serial, x.Description, x.UnitModelId, x.Address, x.City, x.Region, x.Country, x.Latitude, x.Longitude }) + .FirstOrDefaultAsync(); + o.UnitViz = unitInfo.Serial; + o.UnitDescriptionViz = unitInfo.Description; + + if (populateForReporting) + { + o.AddressViz = unitInfo.Address; + o.CityViz = unitInfo.City; + o.RegionViz = unitInfo.Region; + o.CountryViz = unitInfo.Country; + o.LatitudeViz = unitInfo.Latitude; + o.LongitudeViz = unitInfo.Longitude; + } + + if (unitInfo.UnitModelId != null) + { + var unitModelInfo = await ct.UnitModel.AsNoTracking().Where(x => x.Id == unitInfo.UnitModelId).Select(x => new { x.Name, x.VendorId, x.Number }).FirstOrDefaultAsync(); + o.UnitModelNameViz = unitModelInfo.Name; + o.UnitModelModelNumberViz = unitModelInfo.Number; + + if (unitModelInfo.VendorId != null) + o.UnitModelVendorViz = await ct.Vendor.AsNoTracking().Where(x => x.Id == unitModelInfo.VendorId).Select(x => x.Name).FirstOrDefaultAsync(); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + private async Task UnitValidateAsync(QuoteItemUnit proposedObj, QuoteItemUnit currentObj) + { + //skip validation if seeding + // if (ServerBootConfig.SEEDING) return; + + // - A work order *MUST* have only one Unit with a Contract, if there is already a unit with a contract on this quote then a new one cannot be added and it will reject with a validation error + // a unit record is saved only *after* there is already a header (by api users and our client software) so can easily check and set here + + //run validation and biz rules + bool isNew = currentObj == null; + + if (UserIsRestrictedType) + { + //Units: no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (proposedObj.QuoteItemId == 0) + { + AddError(ApiErrorCode.VALIDATION_REQUIRED, "QuoteItemId"); + return;//this is a completely disqualifying error + } + else if (!await ItemExistsAsync(proposedObj.QuoteItemId)) + { + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "QuoteItemId"); + return;//this is a completely disqualifying error + } + //Check state if updatable right now + if (!isNew) + { + //Front end is coded to save the state first before any other updates if it has changed and it would not be + //a part of this header update so it's safe to check it here as it will be most up to date + var CurrentWoStatus = await GetCurrentQuoteStatusFromRelatedAsync(proposedObj.AyaType, proposedObj.Id); + if (CurrentWoStatus.Locked) + { + AddError(ApiErrorCode.VALIDATION_NOT_CHANGEABLE, "generalerror", await Translate("QuoteErrorLocked")); + return;//this is a completely disqualifying error + } + } + + if (proposedObj.UnitId < 1 || !await ct.Unit.AnyAsync(x => x.Id == proposedObj.UnitId)) + AddError(ApiErrorCode.VALIDATION_INVALID_VALUE, "UnitId"); + + // //Contracted unit? Only one per work order is allowed + // if (isNew || proposedObj.UnitId != currentObj.UnitId) + // { + // bool AlreadyHasAContractedUnit = false; + // //See if this unit has a contract, if so then see if the contract is active, if so then iterate quote graph and check all other units for same + // //if any found then reject this + // var proposedUnitInfo = await ct.Unit.AsNoTracking().Where(x => x.Id == proposedObj.UnitId).Select(x => new { x.ContractExpires, x.ContractId }).FirstOrDefaultAsync(); + // if (proposedUnitInfo.ContractId != null && proposedUnitInfo.ContractExpires > DateTime.UtcNow) + // { + // //added woitemunit has a contract and apparently unexpired so need to check if contract is still active + // newOrChangedActiveUnitContractId = proposedUnitInfo.ContractId; + // if (await ct.Contract.AsNoTracking().Where(z => z.Id == proposedUnitInfo.ContractId).Select(x => x.Active).FirstOrDefaultAsync() == true) + // { + // //iterate work order and check for other contracted unit + // var woId = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, proposedObj.QuoteItemId, ct); + // newOrChangedActiveUnitQuoteId = woId.QuoteId;//save for later contract update if necessary + // var w = await QuoteGetFullAsync(woId.QuoteId); + + // //iterate, look for *other* woitemunit records, are they contracted already? + // foreach (QuoteItem wi in w.Items) + // { + // if (AlreadyHasAContractedUnit) continue; + // foreach (QuoteItemUnit wiu in wi.Units) + // { + // if (isNew || wiu.Id != currentObj.Id) + // { + // var existingUnitInfo = await ct.Unit.AsNoTracking().Where(x => x.Id == wiu.UnitId).Select(x => new { x.ContractExpires, x.ContractId }).FirstOrDefaultAsync(); + // if (existingUnitInfo != null) + // { + // if (existingUnitInfo.ContractId != null && existingUnitInfo.ContractExpires > DateTime.UtcNow) + // { + // //Ok, we have a pre-existing contract, is it active? + // if (await ct.Contract.AsNoTracking().Where(x => x.Id == existingUnitInfo.ContractId).Select(x => x.Active).FirstOrDefaultAsync()) + // { + // AlreadyHasAContractedUnit = true; + // continue; + // } + // } + // } + // } + // } + // } + // if (AlreadyHasAContractedUnit) + // { + // AddError(ApiErrorCode.VALIDATION_WO_MULTIPLE_CONTRACTED_UNITS, "UnitId"); + // return;//this is a completely disqualifying error + // } + // } + // else + // { + // newOrChangedActiveUnitContractId = null;//just in case it's non active but present so later biz actions don't process it + // } + // } + // } + + + + + //Any form customizations to validate? + var FormCustomization = await ct.FormCustom.AsNoTracking().SingleOrDefaultAsync(z => z.FormKey == AyaType.QuoteItemUnit.ToString()); + if (FormCustomization != null) + { + //Yeppers, do the validation, there are two, the custom fields and the regular fields that might be set to required + + //validate users choices for required non custom fields + RequiredFieldsValidator.Validate(this, FormCustomization, proposedObj);//note: this is passed only to add errors + + //validate custom fields + CustomFieldsValidator.Validate(this, FormCustomization, proposedObj.CustomFields); + } + } + + + private void UnitValidateCanDelete(QuoteItemUnit obj) + { + if (UserIsRestrictedType) + { + //Units: no edits allowed + AddError(ApiErrorCode.NOT_AUTHORIZED, "generalerror"); + return; + } + + if (obj == null) + { + AddError(ApiErrorCode.NOT_FOUND, "id"); + return; + } + + //re-check rights here necessary due to traversal delete from Principle object + if (!Authorized.HasDeleteRole(CurrentUserRoles, AyaType.QuoteItemUnit)) + { + AddError(ApiErrorCode.NOT_AUTHORIZED); + return; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + // NOTIFICATION PROCESSING + // + public async Task UnitHandlePotentialNotificationEvent(AyaEvent ayaEvent, ICoreBizObjectModel proposedObj, ICoreBizObjectModel currentObj = null) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + if (ServerBootConfig.SEEDING) return; + log.LogDebug($"HandlePotentialNotificationEvent processing: [AyaType:{proposedObj.AyaType}, AyaEvent:{ayaEvent}]"); + + bool isNew = currentObj == null; + QuoteItemUnit oProposed = (QuoteItemUnit)proposedObj; + var wid = await GetQuoteIdFromRelativeAsync(AyaType.QuoteItem, oProposed.QuoteItemId, ct); + var WorkorderInfo = await ct.Quote.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + oProposed.Name = WorkorderInfo.Serial.ToString();//for notification purposes because has no name field itself + + //STANDARD EVENTS FOR ALL OBJECTS + await NotifyEventHelper.ProcessStandardObjectEvents(ayaEvent, proposedObj, ct); + + //SPECIFIC EVENTS FOR THIS OBJECT + + + }//end of process notifications + + + + #endregion work order item LABOR level + + + + #region Utility + public async Task GetQuoteGraphItem(AyaType ayaType, long id) + { + switch (ayaType) + { + case AyaType.Quote: + return await QuoteGetAsync(id, false) as ICoreBizObjectModel; + case AyaType.QuoteItem: + return await ItemGetAsync(id, false); + case AyaType.QuoteItemExpense: + return await ExpenseGetAsync(id, false); + case AyaType.QuoteItemLabor: + return await LaborGetAsync(id, false); + case AyaType.QuoteItemLoan: + return await LoanGetAsync(id, false); + case AyaType.QuoteItemPart: + return await PartGetAsync(id, false); + case AyaType.QuoteItemScheduledUser: + return await ScheduledUserGetAsync(id, false); + case AyaType.QuoteItemTask: + return await TaskGetAsync(id, false); + case AyaType.QuoteItemTravel: + return await TravelGetAsync(id, false); + case AyaType.QuoteItemUnit: + return await UnitGetAsync(id, false); + case AyaType.QuoteItemOutsideService: + return await OutsideServiceGetAsync(id, false); + default: + throw new System.ArgumentOutOfRangeException($"Quote::GetQuoteGraphItem -> Invalid ayaType{ayaType}"); + } + } + + public async Task PutQuoteGraphItem(AyaType ayaType, ICoreBizObjectModel o) + { + ClearErrors(); + switch (ayaType) + { + case AyaType.Quote: + if (o is Quote) + { + Quote dto = new Quote(); + CopyObject.Copy(o, dto); + return await QuotePutAsync((Quote)dto); + } + return await QuotePutAsync((Quote)o) as ICoreBizObjectModel; + case AyaType.QuoteItem: + if (o is QuoteItem) + { + QuoteItem dto = new QuoteItem(); + CopyObject.Copy(o, dto); + return await ItemPutAsync((QuoteItem)dto); + } + return await ItemPutAsync((QuoteItem)o); + case AyaType.QuoteItemExpense: + return await ExpensePutAsync((QuoteItemExpense)o); + case AyaType.QuoteItemLabor: + return await LaborPutAsync((QuoteItemLabor)o); + case AyaType.QuoteItemLoan: + return await LoanPutAsync((QuoteItemLoan)o); + case AyaType.QuoteItemPart: + return await PartPutAsync((QuoteItemPart)o); + + case AyaType.QuoteItemScheduledUser: + return await ScheduledUserPutAsync((QuoteItemScheduledUser)o); + case AyaType.QuoteItemTask: + return await TaskPutAsync((QuoteItemTask)o); + case AyaType.QuoteItemTravel: + return await TravelPutAsync((QuoteItemTravel)o); + case AyaType.QuoteItemUnit: + return await UnitPutAsync((QuoteItemUnit)o); + case AyaType.QuoteItemOutsideService: + return await OutsideServicePutAsync((QuoteItemOutsideService)o); + default: + throw new System.ArgumentOutOfRangeException($"Quote::PutQuoteGraphItem -> Invalid ayaType{ayaType}"); + } + } + + public async Task DeleteQuoteGraphItem(AyaType ayaType, long id) + { + switch (ayaType) + { + case AyaType.Quote: + return await QuoteDeleteAsync(id); + case AyaType.QuoteItem: + return await ItemDeleteAsync(id); + case AyaType.QuoteItemExpense: + return await ExpenseDeleteAsync(id); + case AyaType.QuoteItemLabor: + return await LaborDeleteAsync(id); + case AyaType.QuoteItemLoan: + return await LoanDeleteAsync(id); + case AyaType.QuoteItemPart: + return await PartDeleteAsync(id); + case AyaType.QuoteItemScheduledUser: + return await ScheduledUserDeleteAsync(id); + case AyaType.QuoteItemTask: + return await TaskDeleteAsync(id); + case AyaType.QuoteItemTravel: + return await TravelDeleteAsync(id); + case AyaType.QuoteItemUnit: + return await UnitDeleteAsync(id); + case AyaType.QuoteItemOutsideService: + return await OutsideServiceDeleteAsync(id); + default: + throw new System.ArgumentOutOfRangeException($"Quote::GetQuoteGraphItem -> Invalid ayaType{ayaType}"); + } + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //GET CONTRACT FOR WORKORDER FROM RELATIVE + // + + //cache the contract to save repeatedly fetching it for this operation + internal Contract mContractInEffect = null; + internal bool mFetchedContractAlready = false;//null contract isn't enough to know it was fetched as it could just not have a contract so this is required + + internal async Task GetCurrentQuoteContractFromRelatedAsync(AyaType ayaType, long id) + { + if (mFetchedContractAlready == false) + { + var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); + var WoContractId = await ct.Quote.AsNoTracking().Where(z => z.Id == wid.ParentId).Select(z => z.ContractId).FirstOrDefaultAsync(); + await GetCurrentContractFromContractIdAsync(WoContractId); + } + return mContractInEffect; + } + + + internal async Task GetCurrentContractFromContractIdAsync(long? id) + { + if (id == null) return null; + if (mFetchedContractAlready == false) + { + mContractInEffect = await GetFullyPopulatedContractGraphFromIdAsync(id); + } + return mContractInEffect; + } + + internal async Task GetFullyPopulatedContractGraphFromIdAsync(long? id) + { + if (id == null) return null; + return await ct.Contract.AsSplitQuery().AsNoTracking() + .Include(c => c.ServiceRateItems) + .Include(c => c.TravelRateItems) + .Include(c => c.ContractPartOverrideItems) + .Include(c => c.ContractTravelRateOverrideItems) + .Include(c => c.ContractServiceRateOverrideItems) + .FirstOrDefaultAsync(z => z.Id == id); + + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //GET CURRENT STATUS FOR WORKORDER FROM RELATIVE + // + + //cache the state to save repeatedly fetching it for this operation which could be called multiple times in a flowv + internal QuoteStatus mCurrentQuoteStatus = null; + + internal async Task GetCurrentQuoteStatusFromRelatedAsync(AyaType ayaType, long id) + { + if (mCurrentQuoteStatus == null) + { + var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); + var stat = await ct.QuoteState.AsNoTracking() + .Where(z => z.QuoteId == wid.ParentId) + .OrderByDescending(z => z.Created) + .Take(1) + .FirstOrDefaultAsync(); + + //no state set yet? + if (stat == null) + mCurrentQuoteStatus = new QuoteStatus() { Id = -1, Locked = false, Completed = false }; + else + mCurrentQuoteStatus = await ct.QuoteStatus.AsNoTracking().Where(z => z.Id == stat.QuoteStatusId).FirstAsync();//this should never not be null + } + return mCurrentQuoteStatus; + } + + // internal static async Task GetCurrentQuoteStatusFromRelatedAsync(AyaType ayaType, long id, AyContext ct) + // { + // //static method + // var wid = await GetQuoteIdFromRelativeAsync(ayaType, id, ct); + // var stat = await ct.QuoteState.AsNoTracking() + // .Where(z => z.QuoteId == wid.ParentId) + // .OrderByDescending(z => z.Created) + // .Take(1) + // .FirstOrDefaultAsync(); + + + // //no state set yet? + // if (stat == null) + // { //default + // return new QuoteStatus() { Id = -1, Locked = false, Completed = false }; + // } + // return await ct.QuoteStatus.AsNoTracking().Where(z => z.Id == stat.QuoteStatusId).FirstAsync();//this should never not be null + + // } + + + + #endregion utility + - //Other job handlers here... ///////////////////////////////////////////////////////////////////// diff --git a/server/AyaNova/biz/WorkOrderBiz.cs b/server/AyaNova/biz/WorkOrderBiz.cs index 0144ca0f..b0ff044e 100644 --- a/server/AyaNova/biz/WorkOrderBiz.cs +++ b/server/AyaNova/biz/WorkOrderBiz.cs @@ -563,15 +563,15 @@ namespace AyaNova.Biz //////////////////////////////////////////////////////////////////////////////////////////////// //GET WORKORDER ID FROM DESCENDANT TYPE AND ID // - internal static async Task GetWorkOrderIdFromRelativeAsync(AyaType ayaType, long id, AyContext ct) + internal static async Task GetWorkOrderIdFromRelativeAsync(AyaType ayaType, long id, AyContext ct) { - WorkorderAndItemId w = new WorkorderAndItemId(); + ParentAndChildItemId w = new ParentAndChildItemId(); long woitemid = 0; switch (ayaType) { case AyaType.WorkOrder: - w.WorkOrderId = id; - w.WorkOrderItemId = 0; + w.ParentId = id; + w.ChildItemId = 0; return w; case AyaType.WorkOrderItem: woitemid = id; @@ -604,8 +604,8 @@ namespace AyaNova.Biz woitemid = await ct.WorkOrderItemOutsideService.AsNoTracking().Where(z => z.Id == id).Select(z => z.WorkOrderItemId).SingleOrDefaultAsync(); break; case AyaType.WorkOrderStatus: - w.WorkOrderId = await ct.WorkOrderState.AsNoTracking().Where(z => z.Id == id).Select(z => z.WorkOrderId).SingleOrDefaultAsync(); - w.WorkOrderItemId = 0; + w.ParentId = await ct.WorkOrderState.AsNoTracking().Where(z => z.Id == id).Select(z => z.WorkOrderId).SingleOrDefaultAsync(); + w.ChildItemId = 0; return w; case AyaType.WorkOrderItemUnit: woitemid = await ct.WorkOrderItemUnit.AsNoTracking().Where(z => z.Id == id).Select(z => z.WorkOrderItemId).SingleOrDefaultAsync(); @@ -614,11 +614,11 @@ namespace AyaNova.Biz throw new System.NotSupportedException($"WorkOrderBiz::GetWorkOrderIdFromRelativeAsync -> AyaType {ayaType.ToString()} is not supported"); } - w.WorkOrderId = await ct.WorkOrderItem.AsNoTracking() + w.ParentId = await ct.WorkOrderItem.AsNoTracking() .Where(z => z.Id == woitemid) .Select(z => z.WorkOrderId) .SingleOrDefaultAsync(); - w.WorkOrderItemId = woitemid; + w.ChildItemId = woitemid; return w; @@ -881,7 +881,7 @@ namespace AyaNova.Biz var wid = await GetWorkOrderIdFromRelativeAsync(ayaType, id, ct); //get header only - var ret = await ct.WorkOrder.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.WorkOrderId); + var ret = await ct.WorkOrder.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.ParentId); //not found don't bomb, just return null if (ret == null) return ret; @@ -907,13 +907,13 @@ namespace AyaNova.Biz .Include(wi => wi.Travels) .Include(wi => wi.Units) .Include(wi => wi.OutsideServices) - .SingleOrDefaultAsync(z => z.Id == wid.WorkOrderItemId); + .SingleOrDefaultAsync(z => z.Id == wid.ChildItemId); } else { //get the single workorder item required - woitem = await ct.WorkOrderItem.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.WorkOrderItemId); + woitem = await ct.WorkOrderItem.AsNoTracking().SingleOrDefaultAsync(x => x.Id == wid.ChildItemId); switch (ayaType) { @@ -2301,7 +2301,7 @@ namespace AyaNova.Biz WorkOrderItem oProposed = (WorkOrderItem)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name field itself oProposed.Name = WorkorderInfo.Serial.ToString(); @@ -2687,7 +2687,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemExpense oProposed = (WorkOrderItemExpense)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Tags = WorkorderInfo.Tags; //STANDARD EVENTS FOR ALL OBJECTS @@ -3089,7 +3089,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemLabor oProposed = (WorkOrderItemLabor)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name or tags field itself oProposed.Name = WorkorderInfo.Serial.ToString(); oProposed.Tags = WorkorderInfo.Tags; @@ -3493,7 +3493,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemLoan oProposed = (WorkOrderItemLoan)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name / tags field itself oProposed.Name = WorkorderInfo.Serial.ToString(); oProposed.Tags = WorkorderInfo.Tags; @@ -3826,7 +3826,7 @@ namespace AyaNova.Biz WorkOrderItemOutsideService oProposed = (WorkOrderItemOutsideService)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); //for notification purposes because has no name / tags field itself oProposed.Name = WorkorderInfo.Serial.ToString(); oProposed.Tags = WorkorderInfo.Tags; @@ -4585,7 +4585,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemPart oProposed = (WorkOrderItemPart)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name / tags field itself oProposed.Tags = WorkorderInfo.Tags; @@ -4863,7 +4863,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemPartRequest oProposed = (WorkOrderItemPartRequest)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); proposedObj.Tags = WorkorderInfo.Tags; proposedObj.Name = WorkorderInfo.Serial.ToString(); @@ -5148,7 +5148,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemScheduledUser oProposed = (WorkOrderItemScheduledUser)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name field itself oProposed.Tags = WorkorderInfo.Tags; //for notification purposes because has no tag field itself @@ -5555,7 +5555,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemTask oProposed = (WorkOrderItemTask)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString(); //for notification purposes because has no name / tags field itself oProposed.Tags = WorkorderInfo.Tags; @@ -5937,7 +5937,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemTravel oProposed = (WorkOrderItemTravel)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString();//for notification purposes because has no name / tags field itself oProposed.Tags = WorkorderInfo.Tags; @@ -6341,7 +6341,7 @@ namespace AyaNova.Biz bool isNew = currentObj == null; WorkOrderItemUnit oProposed = (WorkOrderItemUnit)proposedObj; var wid = await GetWorkOrderIdFromRelativeAsync(AyaType.WorkOrderItem, oProposed.WorkOrderItemId, ct); - var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.WorkOrderId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); + var WorkorderInfo = await ct.WorkOrder.AsNoTracking().Where(x => x.Id == wid.ParentId).Select(x => new { Serial = x.Serial, Tags = x.Tags }).FirstOrDefaultAsync(); oProposed.Name = WorkorderInfo.Serial.ToString();//for notification purposes because has no name field itself //STANDARD EVENTS FOR ALL OBJECTS @@ -6486,7 +6486,7 @@ namespace AyaNova.Biz if (mFetchedContractAlready == false) { var wid = await GetWorkOrderIdFromRelativeAsync(ayaType, id, ct); - var WoContractId = await ct.WorkOrder.AsNoTracking().Where(z => z.Id == wid.WorkOrderId).Select(z => z.ContractId).FirstOrDefaultAsync(); + var WoContractId = await ct.WorkOrder.AsNoTracking().Where(z => z.Id == wid.ParentId).Select(z => z.ContractId).FirstOrDefaultAsync(); await GetCurrentContractFromContractIdAsync(WoContractId); } return mContractInEffect; @@ -6530,7 +6530,7 @@ namespace AyaNova.Biz { var wid = await GetWorkOrderIdFromRelativeAsync(ayaType, id, ct); var stat = await ct.WorkOrderState.AsNoTracking() - .Where(z => z.WorkOrderId == wid.WorkOrderId) + .Where(z => z.WorkOrderId == wid.ParentId) .OrderByDescending(z => z.Created) .Take(1) .FirstOrDefaultAsync(); diff --git a/server/AyaNova/models/QuoteItem.cs b/server/AyaNova/models/QuoteItem.cs index 7787e2c4..578843a8 100644 --- a/server/AyaNova/models/QuoteItem.cs +++ b/server/AyaNova/models/QuoteItem.cs @@ -25,8 +25,8 @@ namespace AyaNova.Models [Required] public long QuoteId { get; set; } public string TechNotes { get; set; } - public long? QuoteItemStatusId { get; set; } - public long? QuoteItemPriorityId { get; set; } + public long? WorkOrderItemStatusId { get; set; } + public long? WorkOrderItemPriorityId { get; set; } public DateTime? RequestDate { get; set; } public bool WarrantyService { get; set; } = false; public int Sequence { get; set; } diff --git a/server/AyaNova/models/dto/ParentAndChildItemId.cs b/server/AyaNova/models/dto/ParentAndChildItemId.cs new file mode 100644 index 00000000..9d0d7a37 --- /dev/null +++ b/server/AyaNova/models/dto/ParentAndChildItemId.cs @@ -0,0 +1,8 @@ +namespace AyaNova.Models +{ + public class ParentAndChildItemId + { + public long ParentId { get; set; } + public long ChildItemId { get; set; } + } +} diff --git a/server/AyaNova/models/dto/WorkorderAndItemId.cs b/server/AyaNova/models/dto/WorkorderAndItemId.cs deleted file mode 100644 index ea66e505..00000000 --- a/server/AyaNova/models/dto/WorkorderAndItemId.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace AyaNova.Models -{ - public class WorkorderAndItemId - { - public long WorkOrderId { get; set; } - public long WorkOrderItemId { get; set; } - } -} diff --git a/server/AyaNova/util/AySchema.cs b/server/AyaNova/util/AySchema.cs index c5a03547..772d5bd0 100644 --- a/server/AyaNova/util/AySchema.cs +++ b/server/AyaNova/util/AySchema.cs @@ -777,7 +777,7 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); await ExecQueryAsync("CREATE TABLE aworkorder (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, serial BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, " + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY, customerid BIGINT NOT NULL REFERENCES acustomer (id), " + "projectid BIGINT REFERENCES aproject, laststatusid BIGINT REFERENCES aworkorderstatus(id), contractid BIGINT NULL, internalreferencenumber text, " - +" customerreferencenumber text, customercontactname text, createddate TIMESTAMP NOT NULL, " + + " customerreferencenumber text, customercontactname text, createddate TIMESTAMP NOT NULL, " + "servicedate TIMESTAMP, completebydate TIMESTAMP, invoicenumber TEXT, customersignature TEXT, customersignaturename TEXT, customersignaturecaptured TIMESTAMP, " + "techsignature TEXT, techsignaturename TEXT, techsignaturecaptured TIMESTAMP, durationtocompleted INTERVAL NOT NULL, onsite BOOL NOT NULL, " + "postaddress TEXT, postcity TEXT, postregion TEXT, postcountry TEXT, postcode TEXT, address TEXT, city TEXT, region TEXT, country TEXT, latitude DECIMAL(9,6), longitude DECIMAL(9,6) " @@ -891,12 +891,12 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); + "left outer join vpartsonorder on (vpartinventorynow.partid = vpartsonorder.partid and vpartinventorynow.partwarehouseid = vpartsonorder.partwarehouseid)"); - //VIEWWORKORDER - adds AGE expression column for datalist queries + //VIEWWORKORDER - adds AGE expression column for datalist queries await ExecQueryAsync("CREATE VIEW viewworkorder AS select aworkorder.*, AGE(timezone('UTC', now()), aworkorder.createddate) as expwoage from aworkorder"); //---------- - + /* ██████╗ ██╗ ██╗ ██████╗ ████████╗███████╗ @@ -905,19 +905,88 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); ██║▄▄ ██║██║ ██║██║ ██║ ██║ ██╔══╝ ╚██████╔╝╚██████╔╝╚██████╔╝ ██║ ███████╗ ╚══▀▀═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝ - */ + */ + + + //QUOTE + await ExecQueryAsync("CREATE TABLE aquote (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, serial BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, " + + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY, customerid BIGINT NOT NULL REFERENCES acustomer (id), " + + "projectid BIGINT REFERENCES aproject, laststatusid BIGINT REFERENCES aquotestatus(id), contractid BIGINT NULL, internalreferencenumber text, " + + "customerreferencenumber text, customercontactname text, createddate TIMESTAMP NOT NULL, " + + "preparedbyid BIGINT REFERENCES auser(id), introduction TEXT, requested TIMESTAMP, validuntil TIMESTAMP, submitted TIMESTAMP, approved TIMESTAMP, " + + "copywiki BOOL NOT NULL, copyattachments BOOL NOT NULL, onsite BOOL NOT NULL, " + + "postaddress TEXT, postcity TEXT, postregion TEXT, postcountry TEXT, postcode TEXT, address TEXT, city TEXT, region TEXT, country TEXT, latitude DECIMAL(9,6), longitude DECIMAL(9,6) " + + ")"); + + await ExecQueryAsync("CREATE TABLE aquotestate (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteid BIGINT NOT NULL REFERENCES aquote (id), " + + "quotestatusid BIGINT NOT NULL REFERENCES aquotestatus (id), created TIMESTAMP NOT NULL, userid BIGINT NOT NULL REFERENCES auser (id)" + + ")"); + + //QUOTEITEM + await ExecQueryAsync("CREATE TABLE aquoteitem (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteid BIGINT NOT NULL REFERENCES aquote (id), " + + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY, technotes TEXT, workorderitemstatusid BIGINT REFERENCES aworkorderitemstatus (id), " + + " workorderitempriorityid BIGINT REFERENCES aworkorderitempriority (id), requestdate TIMESTAMP, warrantyservice BOOL NOT NULL, sequence INTEGER" + + ")"); + + //QUOTEITEM EXPENSE + await ExecQueryAsync("CREATE TABLE aquoteitemexpense (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "description TEXT, name TEXT, totalcost DECIMAL(38,18) NOT NULL default 0, chargeamount DECIMAL(38,18) NOT NULL default 0, taxpaid DECIMAL(38,18) NOT NULL default 0, " + + "chargetaxcodeid BIGINT REFERENCES ataxcode, reimburseuser BOOL NOT NULL, userid BIGINT REFERENCES auser, chargetocustomer BOOL NOT NULL " + + ")"); + + //QUOTEITEM LABOR + await ExecQueryAsync("CREATE TABLE aquoteitemlabor (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "userid BIGINT REFERENCES auser, servicestartdate TIMESTAMP, servicestopdate TIMESTAMP, servicerateid BIGINT REFERENCES aservicerate, servicedetails text, " + + "serviceratequantity DECIMAL(19,5) NOT NULL default 0, nochargequantity DECIMAL(19,5) NOT NULL default 0, " + + "taxcodesaleid BIGINT REFERENCES ataxcode, priceoverride DECIMAL(38,18) " + + ")"); + + //QUOTEITEM LOAN + await ExecQueryAsync("CREATE TABLE aquoteitemloan (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "notes TEXT, outdate TIMESTAMP, duedate TIMESTAMP, returndate TIMESTAMP,cost DECIMAL(38,18) NOT NULL default 0, listprice DECIMAL(38,18) NOT NULL default 0, priceoverride DECIMAL(38,18), " + + "taxcodeid BIGINT REFERENCES ataxcode, loanunitid BIGINT NOT NULL REFERENCES aloanunit, quantity DECIMAL(19,5) NOT NULL default 0, rate INTEGER NOT NULL" + + ")"); + + //QUOTEITEM PART + await ExecQueryAsync("CREATE TABLE aquoteitempart (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "description TEXT, serials TEXT, partid BIGINT NOT NULL REFERENCES apart, partwarehouseid BIGINT NOT NULL REFERENCES apartwarehouse, quantity DECIMAL(19,5) NOT NULL default 0, " + + "cost DECIMAL(38,18) NOT NULL default 0, listprice DECIMAL(38,18) NOT NULL default 0, taxpartsaleid BIGINT REFERENCES ataxcode, priceoverride DECIMAL(38,18) " + + ")"); + + //QUOTEITEM SCHEDULED USER + await ExecQueryAsync("CREATE TABLE aquoteitemscheduleduser (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "userid BIGINT REFERENCES auser, startdate TIMESTAMP, stopdate TIMESTAMP, servicerateid BIGINT REFERENCES aservicerate, " + + "estimatedquantity DECIMAL(19,5) NOT NULL default 0" + + ")"); + + //QUOTEITEM TASK + await ExecQueryAsync("CREATE TABLE aquoteitemtask (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "sequence INTEGER NOT NULL DEFAULT 0, task text NOT NULL, status INTEGER NOT NULL DEFAULT 1, completedbyuserid BIGINT REFERENCES auser, completeddate TIMESTAMP" + + ")"); + + //QUOTEITEM TRAVEL + await ExecQueryAsync("CREATE TABLE aquoteitemtravel (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "userid BIGINT REFERENCES auser, travelstartdate TIMESTAMP, travelstopdate TIMESTAMP, travelrateid BIGINT REFERENCES atravelrate, traveldetails text, " + + "travelratequantity DECIMAL(19,5) NOT NULL default 0, nochargequantity DECIMAL(19,5) NOT NULL default 0, " + + "taxcodesaleid BIGINT REFERENCES ataxcode, distance DECIMAL(19,5) NOT NULL default 0, priceoverride DECIMAL(38,18) " + + ")"); + + //QUOTEITEM UNIT + await ExecQueryAsync("CREATE TABLE aquoteitemunit (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY, unitid BIGINT NOT NULL REFERENCES aunit" + + ")"); + + //QUOTEITEM OUTSIDE SERVICE + await ExecQueryAsync("CREATE TABLE aquoteitemoutsideservice (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteitemid BIGINT NOT NULL REFERENCES aquoteitem (id), " + + "notes TEXT, unitid BIGINT NOT NULL REFERENCES aunit, vendorsenttoid BIGINT REFERENCES avendor, vendorsentviaid BIGINT REFERENCES avendor, rmanumber text, trackingnumber text, " + + "taxcodeid BIGINT REFERENCES ataxcode, repaircost DECIMAL(38,18) NOT NULL default 0, repairprice DECIMAL(38,18) NOT NULL default 0, shippingcost DECIMAL(38,18) NOT NULL default 0, shippingprice DECIMAL(38,18) NOT NULL default 0, " + + "SentDate TIMESTAMP, etadate TIMESTAMP, returndate TIMESTAMP" + + ")"); - // //QUOTE - // await ExecQueryAsync("CREATE TABLE aquote (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, serial BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, active BOOL NOT NULL, " - // + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY )"); - - // //QUOTEITEM - // await ExecQueryAsync("CREATE TABLE aquoteitem (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, quoteid BIGINT NOT NULL REFERENCES aquote (id), name TEXT NOT NULL UNIQUE, active BOOL NOT NULL, " - // + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY )"); - + /////////////////////////////////////////////////////////////////////////////////////// //PM await ExecQueryAsync("CREATE TABLE apm (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, serial BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, active BOOL NOT NULL, " @@ -926,7 +995,7 @@ $BODY$ LANGUAGE PLPGSQL STABLE"); //PMITEM await ExecQueryAsync("CREATE TABLE apmitem (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, pmid BIGINT NOT NULL REFERENCES apm (id), name TEXT NOT NULL UNIQUE, active BOOL NOT NULL, " + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY )"); - + //CUSTOMERSERVICEREQUEST await ExecQueryAsync("CREATE TABLE acustomerservicerequest (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, " + "notes TEXT, wiki TEXT, customfields TEXT, tags VARCHAR(255) ARRAY, " diff --git a/server/AyaNova/util/DbUtil.cs b/server/AyaNova/util/DbUtil.cs index 7d1d3711..dab74313 100644 --- a/server/AyaNova/util/DbUtil.cs +++ b/server/AyaNova/util/DbUtil.cs @@ -361,8 +361,6 @@ namespace AyaNova.Util await EraseTableAsync("aworkorderitem", conn); await EraseTableAsync("aworkorderstate", conn); await EraseTableAsync("aworkorder", conn); - await EraseTableAsync("aworkordertemplateitem", conn); - await EraseTableAsync("aworkordertemplate", conn); //--- @@ -387,16 +385,31 @@ namespace AyaNova.Util await EraseTableAsync("apartassembly", conn); await EraseTableAsync("apartinventory", conn); await EraseTableAsync("apart", conn); + + + + //--- QUOTE + await EraseTableAsync("aquoteitemexpense", conn); + await EraseTableAsync("aquoteitemlabor", conn); + await EraseTableAsync("aquoteitemloan", conn); + await EraseTableAsync("aquoteitempart", conn); + await EraseTableAsync("aquoteitemscheduleduser", conn); + await EraseTableAsync("aquoteitemtask", conn); + await EraseTableAsync("aquoteitemtravel", conn); + await EraseTableAsync("aquoteitemunit", conn); + await EraseTableAsync("aquoteitemoutsideservice", conn); + await EraseTableAsync("aquoteitem", conn); + await EraseTableAsync("aquotestate", conn); + await EraseTableAsync("aquote", conn); + //--- + + await EraseTableAsync("apmitem", conn); await EraseTableAsync("apm", conn); - await EraseTableAsync("apmtemplateitem", conn); - await EraseTableAsync("apmtemplate", conn); - await EraseTableAsync("aquoteitem", conn); - await EraseTableAsync("aquote", conn); - await EraseTableAsync("aquotetemplateitem", conn); - await EraseTableAsync("aquotetemplate", conn); + + await EraseTableAsync("aunitmodel", conn); await EraseTableAsync("avendor", conn); @@ -433,12 +446,12 @@ namespace AyaNova.Util //after cleanup using (var cmd = new Npgsql.NpgsqlCommand()) - { + { cmd.Connection = conn; cmd.CommandText = "delete from \"auseroptions\" where UserId <> 1;"; await cmd.ExecuteNonQueryAsync(); cmd.CommandText = "ALTER SEQUENCE auseroptions_id_seq RESTART WITH 2;"; - await cmd.ExecuteNonQueryAsync(); + await cmd.ExecuteNonQueryAsync(); cmd.CommandText = "delete from \"auser\" where id <> 1;"; await cmd.ExecuteNonQueryAsync(); cmd.CommandText = "ALTER SEQUENCE auser_id_seq RESTART WITH 2;"; @@ -460,8 +473,8 @@ namespace AyaNova.Util cmd.CommandText = "ALTER SEQUENCE apm_serial_seq RESTART WITH 1;"; await cmd.ExecuteNonQueryAsync(); - - + + }