重构产品价和红线价功能

This commit is contained in:
wuweihua
2026-10-18 14:16:27 +08:00
parent db6f5e882a
commit 60d4614695
84 changed files with 6753 additions and 2843 deletions
@@ -28,6 +28,7 @@ public class BmsApplication
" | (_,_).' __ ___(_ o _)' \n" +
" | |\\ \\ | || |(_,_)' \n" +
" | | \\ `' /| `-' / \n" +
" | | \\ / \\ / \n" +
" ''-' `'-' `-..-' ");
}
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.price.service.support.UploadFileBatch;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
@@ -167,7 +168,7 @@ public class PriceSetController extends BaseController
return toAjax(priceSetService.deleteVersion(psvId, getUsername()));
}
/** Import excel attachments for a version (via prc_price_set_product.psv_id / pef_id). */
/** Import excel attachments for a version (prc_price_excel_file.psv_id). */
@PreAuthorize("@ss.hasPermi('price:set:list')")
@GetMapping("/version/{psvId}/excelFiles")
public AjaxResult versionExcelFiles(@PathVariable Long psvId)
@@ -255,7 +256,9 @@ public class PriceSetController extends BaseController
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit')")
@Log(title = "\u5957\u9910\u4ef7\u683c\u5bfc\u5165", businessType = BusinessType.IMPORT)
@PostMapping("/detail/importPrices")
public AjaxResult importDetailPrices(MultipartFile file,
public AjaxResult importDetailPrices(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam("priceSetId") Long priceSetId,
@RequestParam(value = "psvId", required = false) Long psvId,
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
@@ -263,8 +266,8 @@ public class PriceSetController extends BaseController
@RequestParam(value = "countryCode", required = false) String countryCode)
throws Exception
{
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, file, getUsername(), updateOnly,
replaceByProductCountry, countryCode);
String taskId = priceSetService.submitImportDetailPricesAsync(priceSetId, psvId, UploadFileBatch.collect(files, file),
getUsername(), updateOnly, replaceByProductCountry, countryCode);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
@@ -286,23 +289,29 @@ public class PriceSetController extends BaseController
*/
@PreAuthorize("@ss.hasPermi('price:set:edit') or @ss.hasPermi('price:set:forceEdit') or @ss.hasPermi('price:set:add')")
@PostMapping("/detail/previewImportPrices")
public AjaxResult previewImportPrices(MultipartFile file,
public AjaxResult previewImportPrices(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(value = "updateOnly", required = false, defaultValue = "false") boolean updateOnly,
@RequestParam(value = "replaceByProductCountry", required = false, defaultValue = "false") boolean replaceByProductCountry,
@RequestParam(value = "countryCode", required = false) String countryCode,
@RequestParam(value = "detailsJson", required = false) String detailsJson) throws Exception
{
if (file == null || file.isEmpty())
java.util.List<MultipartFile> batch = UploadFileBatch.collect(files, file);
java.util.List<byte[]> bytesList = new java.util.ArrayList<byte[]>();
java.util.List<String> names = new java.util.ArrayList<String>();
for (MultipartFile item : batch)
{
return error("file required");
bytesList.add(item.getBytes());
names.add(item.getOriginalFilename());
}
List<PrcPriceSetDetail> existing = null;
if (StringUtils.isNotEmpty(detailsJson))
{
existing = com.alibaba.fastjson2.JSON.parseArray(detailsJson, PrcPriceSetDetail.class);
}
return success(priceSetService.previewImportDetailPrices(
file.getBytes(), file.getOriginalFilename(), updateOnly, existing, replaceByProductCountry, countryCode));
return success(priceSetService.previewImportDetailPricesMany(
bytesList, names, updateOnly, existing, replaceByProductCountry, countryCode));
}
/**
@@ -7,7 +7,6 @@ import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -24,12 +23,17 @@ import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.price.service.support.UploadFileBatch;
import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.BmsProductImportTask;
import com.bms.price.domain.CatalogPreviewApplyRequest;
import com.bms.price.domain.PriceType;
import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSetVersion;
import com.bms.price.service.IBmsProductService;
import com.bms.price.service.IPrcPriceExcelFileService;
import com.bms.price.service.support.CatalogVersionSupport;
/**
* Product list (async Excel import)
@@ -46,6 +50,9 @@ public class ProductController extends BaseController
@Autowired
private IPrcPriceExcelFileService priceExcelFileService;
@Autowired
private CatalogVersionSupport catalogVersionSupport;
@PreAuthorize("@ss.hasPermi('price:product:list')")
@GetMapping("/list")
public TableDataInfo list(BmsProduct product)
@@ -55,15 +62,6 @@ public class ProductController extends BaseController
return getDataTable(list);
}
/** Imported quote Excel attachments for this product_code (via product.pef_id). */
@PreAuthorize("@ss.hasPermi('price:product:query')")
@GetMapping("/{productCode}/excelFiles")
public AjaxResult excelFiles(@PathVariable String productCode)
{
List<PrcPriceExcelFile> list = priceExcelFileService.selectByProductCode(productCode);
return success(list);
}
/**
* 产品下拉选项(非分页)。
* 供计费列表、客户绑定等场景选择产品;不做权限控制(仍需登录)。
@@ -97,29 +95,93 @@ public class ProductController extends BaseController
return success(list);
}
@PreAuthorize("@ss.hasPermi('price:product:query')")
@GetMapping("/{productId}")
public AjaxResult getInfo(@PathVariable Long productId)
@PreAuthorize("@ss.hasPermi('price:product:list')")
@GetMapping("/versions")
public TableDataInfo versions(@RequestParam(required = false) String status,
@RequestParam(required = false) String statusGroup)
{
return success(productService.selectBmsProductById(productId));
startPage();
List<PrcPriceSetVersion> list = catalogVersionSupport.listVersions(PriceType.PRODUCT, status, statusGroup);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:product:remove')")
@Log(title = "product version delete", businessType = BusinessType.DELETE)
@DeleteMapping("/version/{psvId}")
public AjaxResult deleteVersion(@PathVariable Long psvId)
{
return toAjax(catalogVersionSupport.deleteDraftVersion(psvId));
}
@PreAuthorize("@ss.hasPermi('price:product:query')")
@GetMapping("/version/draft")
public AjaxResult draftVersion()
{
return success(catalogVersionSupport.currentDraft(PriceType.PRODUCT));
}
@PreAuthorize("@ss.hasPermi('price:product:add')")
@Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody BmsProduct product)
@Log(title = "product version prepare", businessType = BusinessType.INSERT)
@PostMapping("/version/prepare")
public AjaxResult prepareVersion(@RequestParam(defaultValue = "false") boolean copyFromLatest)
{
product.setCreateBy(getUsername());
return toAjax(productService.insertBmsProduct(product));
return success(catalogVersionSupport.prepareDraftVersion(PriceType.PRODUCT, copyFromLatest));
}
@PreAuthorize("@ss.hasPermi('price:product:edit')")
@Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody BmsProduct product)
@Log(title = "product version activate", businessType = BusinessType.UPDATE)
@PutMapping("/version/activate/{psvId}")
public AjaxResult activateVersion(@PathVariable Long psvId,
@RequestBody(required = false) PrcPriceSetVersion body)
{
product.setUpdateBy(getUsername());
return toAjax(productService.updateBmsProduct(product));
Date effectiveTime = body == null ? null : body.getEffectiveTime();
catalogVersionSupport.activateVersion(psvId, effectiveTime);
return success();
}
/** Persist create-page preview details into draft/pending version. */
@PreAuthorize("@ss.hasPermi('price:product:add')")
@Log(title = "product preview apply", businessType = BusinessType.UPDATE)
@PostMapping("/version/applyPreview")
public AjaxResult applyPreview(@RequestBody CatalogPreviewApplyRequest body)
{
return success(productService.applyCatalogPreview(body));
}
/** Async parallel save of create-page preview. Returns taskId; poll /import/task/{taskId}. */
@PreAuthorize("@ss.hasPermi('price:product:add')")
@Log(title = "product preview apply", businessType = BusinessType.UPDATE)
@PostMapping("/version/applyPreviewAsync")
public AjaxResult applyPreviewAsync(@RequestBody CatalogPreviewApplyRequest body)
{
String taskId = productService.submitApplyCatalogPreviewAsync(body);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/** Save import Excel attachment onto a draft/pending version (prc_price_excel_file). */
@PreAuthorize("@ss.hasPermi('price:product:import')")
@Log(title = "product version excel", businessType = BusinessType.IMPORT)
@PostMapping("/version/{psvId}/excel")
public AjaxResult attachExcel(@PathVariable Long psvId, MultipartFile file) throws Exception
{
catalogVersionSupport.requireEditableCatalogVersion(psvId, PriceType.PRODUCT);
if (file == null || file.isEmpty())
{
return error(MessageUtils.message("price.product.import.fileEmpty"));
}
PrcPriceExcelFile row = priceExcelFileService.saveImportExcel(
PrcPriceExcelFile.TYPE_PRODUCT, null, file.getBytes(), file.getOriginalFilename(), getUsername(), psvId);
return success(row);
}
/** List Excel attachments of a version. */
@PreAuthorize("@ss.hasPermi('price:product:query')")
@GetMapping("/version/{psvId}/excelFiles")
public AjaxResult listVersionExcelFiles(@PathVariable Long psvId)
{
return success(priceExcelFileService.selectByPsvId(psvId));
}
@PreAuthorize("@ss.hasPermi('price:product:edit')")
@@ -146,7 +208,7 @@ public class ProductController extends BaseController
@PreAuthorize("@ss.hasPermi('price:product:import')")
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file, boolean updateSupport,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
{
String taskId = productService.submitImportProductExcelAsync(file, updateSupport, getUsername(), effectiveDate);
Map<String, Object> payload = new HashMap<String, Object>();
@@ -162,7 +224,7 @@ public class ProductController extends BaseController
@PreAuthorize("@ss.hasPermi('price:product:import')")
@PostMapping("/importCountryData")
public AjaxResult importCountryData(MultipartFile file, String countryCode,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
{
String taskId = productService.submitImportCountryProductExcelAsync(file, countryCode, getUsername(),
effectiveDate);
@@ -170,4 +232,31 @@ public class ProductController extends BaseController
payload.put("taskId", taskId);
return success(payload);
}
/** Async parse-only preview; returns taskId, poll /import/task/{taskId} for result. */
@PreAuthorize("@ss.hasPermi('price:product:import')")
@PostMapping("/previewImport")
public AjaxResult previewImport(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file) throws Exception
{
String taskId = productService.submitPreviewImportAsync(UploadFileBatch.collect(files, file));
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/** Async single-country parse-only preview; returns taskId, poll /import/task/{taskId}. */
@PreAuthorize("@ss.hasPermi('price:product:import')")
@PostMapping("/previewImportCountry")
public AjaxResult previewImportCountry(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
String countryCode) throws Exception
{
String taskId = productService.submitPreviewImportCountryAsync(UploadFileBatch.collect(files, file), countryCode);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
}
@@ -7,7 +7,6 @@ import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -24,12 +23,17 @@ import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.price.service.support.UploadFileBatch;
import com.bms.price.domain.BmsRedlineProduct;
import com.bms.price.domain.BmsRedlineProductCountry;
import com.bms.price.domain.BmsRedlineProductImportTask;
import com.bms.price.domain.PriceType;
import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSetVersion;
import com.bms.price.domain.RedlineCatalogPreviewApplyRequest;
import com.bms.price.service.IBmsRedlineProductService;
import com.bms.price.service.IPrcPriceExcelFileService;
import com.bms.price.service.support.CatalogVersionSupport;
/**
* redline product list (async Excel import)
@@ -46,6 +50,9 @@ public class RedlineProductController extends BaseController
@Autowired
private IPrcPriceExcelFileService priceExcelFileService;
@Autowired
private CatalogVersionSupport catalogVersionSupport;
@PreAuthorize("@ss.hasPermi('price:redline:list')")
@GetMapping("/list")
public TableDataInfo list(BmsRedlineProduct product)
@@ -55,15 +62,6 @@ public class RedlineProductController extends BaseController
return getDataTable(list);
}
/** Imported quote Excel attachments for this product_code (via product.pef_id). */
@PreAuthorize("@ss.hasPermi('price:redline:query')")
@GetMapping("/{productCode}/excelFiles")
public AjaxResult excelFiles(@PathVariable String productCode)
{
List<PrcPriceExcelFile> list = priceExcelFileService.selectByRedlineProductCode(productCode);
return success(list);
}
/**
* 产品下拉选项(非分页)。
* 供计费列表、客户绑定等场景选择产品;不做权限控制(仍需登录)。
@@ -97,29 +95,93 @@ public class RedlineProductController extends BaseController
return success(list);
}
@PreAuthorize("@ss.hasPermi('price:redline:query')")
@GetMapping("/{productId}")
public AjaxResult getInfo(@PathVariable Long productId)
@PreAuthorize("@ss.hasPermi('price:redline:list')")
@GetMapping("/versions")
public TableDataInfo versions(@RequestParam(required = false) String status,
@RequestParam(required = false) String statusGroup)
{
return success(productService.selectBmsRedlineProductById(productId));
startPage();
List<PrcPriceSetVersion> list = catalogVersionSupport.listVersions(PriceType.REDLINE, status, statusGroup);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:redline:remove')")
@Log(title = "redline version delete", businessType = BusinessType.DELETE)
@DeleteMapping("/version/{psvId}")
public AjaxResult deleteVersion(@PathVariable Long psvId)
{
return toAjax(catalogVersionSupport.deleteDraftVersion(psvId));
}
@PreAuthorize("@ss.hasPermi('price:redline:query')")
@GetMapping("/version/draft")
public AjaxResult draftVersion()
{
return success(catalogVersionSupport.currentDraft(PriceType.REDLINE));
}
@PreAuthorize("@ss.hasPermi('price:redline:add')")
@Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody BmsRedlineProduct product)
@Log(title = "redline version prepare", businessType = BusinessType.INSERT)
@PostMapping("/version/prepare")
public AjaxResult prepareVersion(@RequestParam(defaultValue = "false") boolean copyFromLatest)
{
product.setCreateBy(getUsername());
return toAjax(productService.insertBmsRedlineProduct(product));
return success(catalogVersionSupport.prepareDraftVersion(PriceType.REDLINE, copyFromLatest));
}
@PreAuthorize("@ss.hasPermi('price:redline:edit')")
@Log(title = "\u4ea7\u54c1\u5217\u8868", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody BmsRedlineProduct product)
@Log(title = "redline version activate", businessType = BusinessType.UPDATE)
@PutMapping("/version/activate/{psvId}")
public AjaxResult activateVersion(@PathVariable Long psvId,
@RequestBody(required = false) PrcPriceSetVersion body)
{
product.setUpdateBy(getUsername());
return toAjax(productService.updateBmsRedlineProduct(product));
Date effectiveTime = body == null ? null : body.getEffectiveTime();
catalogVersionSupport.activateVersion(psvId, effectiveTime);
return success();
}
/** Persist create-page preview details into draft/pending version. */
@PreAuthorize("@ss.hasPermi('price:redline:add')")
@Log(title = "redline preview apply", businessType = BusinessType.UPDATE)
@PostMapping("/version/applyPreview")
public AjaxResult applyPreview(@RequestBody RedlineCatalogPreviewApplyRequest body)
{
return success(productService.applyCatalogPreview(body));
}
/** Async parallel save of create-page preview. Returns taskId; poll /import/task/{taskId}. */
@PreAuthorize("@ss.hasPermi('price:redline:add')")
@Log(title = "redline preview apply", businessType = BusinessType.UPDATE)
@PostMapping("/version/applyPreviewAsync")
public AjaxResult applyPreviewAsync(@RequestBody RedlineCatalogPreviewApplyRequest body)
{
String taskId = productService.submitApplyCatalogPreviewAsync(body);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/** Save import Excel attachment onto a draft/pending version (prc_price_excel_file). */
@PreAuthorize("@ss.hasPermi('price:redline:import')")
@Log(title = "redline version excel", businessType = BusinessType.IMPORT)
@PostMapping("/version/{psvId}/excel")
public AjaxResult attachExcel(@PathVariable Long psvId, MultipartFile file) throws Exception
{
catalogVersionSupport.requireEditableCatalogVersion(psvId, PriceType.REDLINE);
if (file == null || file.isEmpty())
{
return error(MessageUtils.message("price.product.import.fileEmpty"));
}
PrcPriceExcelFile row = priceExcelFileService.saveImportExcel(
PrcPriceExcelFile.TYPE_REDLINE, null, file.getBytes(), file.getOriginalFilename(), getUsername(), psvId);
return success(row);
}
/** List Excel attachments of a version. */
@PreAuthorize("@ss.hasPermi('price:redline:query')")
@GetMapping("/version/{psvId}/excelFiles")
public AjaxResult listVersionExcelFiles(@PathVariable Long psvId)
{
return success(priceExcelFileService.selectByPsvId(psvId));
}
@PreAuthorize("@ss.hasPermi('price:redline:edit')")
@@ -146,7 +208,7 @@ public class RedlineProductController extends BaseController
@PreAuthorize("@ss.hasPermi('price:redline:import')")
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file, boolean updateSupport,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
{
String taskId = productService.submitImportProductExcelAsync(file, updateSupport, getUsername(), effectiveDate);
Map<String, Object> payload = new HashMap<String, Object>();
@@ -162,7 +224,7 @@ public class RedlineProductController extends BaseController
@PreAuthorize("@ss.hasPermi('price:redline:import')")
@PostMapping("/importCountryData")
public AjaxResult importCountryData(MultipartFile file, String countryCode,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date effectiveDate) throws Exception
{
String taskId = productService.submitImportCountryProductExcelAsync(file, countryCode, getUsername(),
effectiveDate);
@@ -170,4 +232,31 @@ public class RedlineProductController extends BaseController
payload.put("taskId", taskId);
return success(payload);
}
/** Async parse-only preview; returns taskId, poll /import/task/{taskId} for result. */
@PreAuthorize("@ss.hasPermi('price:redline:import')")
@PostMapping("/previewImport")
public AjaxResult previewImport(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file) throws Exception
{
String taskId = productService.submitPreviewImportAsync(UploadFileBatch.collect(files, file));
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
/** Async single-country parse-only preview; returns taskId, poll /import/task/{taskId}. */
@PreAuthorize("@ss.hasPermi('price:redline:import')")
@PostMapping("/previewImportCountry")
public AjaxResult previewImportCountry(
@RequestParam(value = "files", required = false) MultipartFile[] files,
@RequestParam(value = "file", required = false) MultipartFile file,
String countryCode) throws Exception
{
String taskId = productService.submitPreviewImportCountryAsync(UploadFileBatch.collect(files, file), countryCode);
Map<String, Object> payload = new HashMap<String, Object>();
payload.put("taskId", taskId);
return success(payload);
}
}
@@ -58,11 +58,13 @@ server:
uri-encoding: UTF-8
# 连接数满后的排队数,默认为100
accept-count: 1000
connection-timeout: 600000
max-swallow-size: -1
threads:
# tomcat最大线程数,默认为200
max: 800
max: 200
# Tomcat启动初始化的线程数,默认值10
min-spare: 100
min-spare: 10
# 日志配置
logging:
@@ -94,9 +96,9 @@ spring:
servlet:
multipart:
# 单个文件大小
max-file-size: 10MB
max-file-size: 50MB
# 设置总上传的文件大小
max-request-size: 20MB
max-request-size: 260MB
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss