diff --git a/hangtag-module-oms/hangtag-module-oms-biz/src/main/java/cn/hangtag/module/oms/controller/admin/app/AppSaleOrderController.java b/hangtag-module-oms/hangtag-module-oms-biz/src/main/java/cn/hangtag/module/oms/controller/admin/app/AppSaleOrderController.java index 8a3c178..445296a 100644 --- a/hangtag-module-oms/hangtag-module-oms-biz/src/main/java/cn/hangtag/module/oms/controller/admin/app/AppSaleOrderController.java +++ b/hangtag-module-oms/hangtag-module-oms-biz/src/main/java/cn/hangtag/module/oms/controller/admin/app/AppSaleOrderController.java @@ -1,35 +1,193 @@ package cn.hangtag.module.oms.controller.admin.app; +import cn.hangtag.framework.apilog.core.annotation.ApiAccessLog; import cn.hangtag.framework.common.pojo.CommonResult; +import cn.hangtag.framework.common.pojo.PageParam; import cn.hangtag.framework.common.pojo.PageResult; +import cn.hangtag.framework.common.util.number.NumberUtils; import cn.hangtag.framework.common.util.object.BeanUtils; +import cn.hangtag.framework.excel.core.util.ExcelUtils; +import cn.hangtag.framework.security.core.LoginUser; +import cn.hangtag.framework.security.core.util.SecurityFrameworkUtils; import cn.hangtag.module.oms.controller.admin.productinfo.vo.ProductInfoPageReqVO; import cn.hangtag.module.oms.controller.admin.productinfo.vo.ProductInfoRespVO; +import cn.hangtag.module.oms.controller.admin.saleorder.vo.SaleOrderPageReqVO; +import cn.hangtag.module.oms.controller.admin.saleorder.vo.SaleOrderRemarkReqVO; +import cn.hangtag.module.oms.controller.admin.saleorder.vo.SaleOrderRespVO; +import cn.hangtag.module.oms.controller.admin.saleorder.vo.SaleOrderSaveReqVO; +import cn.hangtag.module.oms.convert.saleorder.SaleOrderConvert; +import cn.hangtag.module.oms.dal.dataobject.customer.CustomerDO; import cn.hangtag.module.oms.dal.dataobject.productinfo.ProductInfoDO; +import cn.hangtag.module.oms.dal.dataobject.saleorder.SaleOrderDO; +import cn.hangtag.module.oms.service.customer.CustomerService; import cn.hangtag.module.oms.service.productinfo.ProductInfoService; import cn.hangtag.module.oms.service.saleorder.SaleOrderService; +import cn.hangtag.module.system.api.user.AdminUserApi; +import cn.hangtag.module.system.api.user.dto.AdminUserRespDTO; +import cn.hutool.core.map.MapUtil; +import com.alibaba.fastjson.JSONObject; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; import java.util.Map; +import static cn.hangtag.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static cn.hangtag.framework.common.pojo.CommonResult.success; @Tag(name = "APP - 销售订单") @RestController -@RequestMapping("/oms/app/saleorder") +@RequestMapping("/oms/app/sale-order") @Validated public class AppSaleOrderController{ @Resource private SaleOrderService saleOrderService; + @Resource + private CustomerService customerService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/create") + @Operation(summary = "创建销售订单") + public CommonResult createSaleOrder(@Valid @RequestBody SaleOrderSaveReqVO createReqVO) { + return success(saleOrderService.createSaleOrder(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新销售订单") + public CommonResult updateSaleOrder(@Valid @RequestBody SaleOrderSaveReqVO updateReqVO) { + saleOrderService.updateSaleOrder(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除销售订单") + @Parameter(name = "id", description = "编号", required = true) + public CommonResult deleteSaleOrder(@RequestParam("id") Long id) { + saleOrderService.deleteSaleOrder(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得销售订单") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + public CommonResult getSaleOrder(@RequestParam("id") Long id) { + SaleOrderDO saleOrder = saleOrderService.getSaleOrder(id); + if(saleOrder == null){ + return success(null); + } + // 拼接数据 + List entrys = saleOrderService.getSaleOrderEntryListByParentId(id); + + CustomerDO customer = customerService.getCustomer(saleOrder.getCustomerId()); + // 1.2 获取修改人 + AdminUserRespDTO updater = adminUserApi.getUser(NumberUtils.parseLong(saleOrder.getUpdater())); + AdminUserRespDTO auditor = adminUserApi.getUser(NumberUtils.parseLong(saleOrder.getAuditor())); + + return success(SaleOrderConvert.INSTANCE.convert(saleOrder,entrys,customer,updater,auditor)); + + //return success(BeanUtils.toBean(saleOrder, SaleOrderRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得销售订单分页") + public CommonResult> getSaleOrderPage(@Valid SaleOrderPageReqVO pageReqVO) { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + CustomerDO customer = customerService.getCustomerByUserId(loginUser.getId()); + pageReqVO.setCustomerId(customer.getId()); + PageResult pageResult = saleOrderService.getSaleOrderPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, SaleOrderRespVO.class)); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出销售订单 Excel") + @ApiAccessLog(operateType = EXPORT) + public void exportSaleOrderExcel(@Valid SaleOrderPageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = saleOrderService.getSaleOrderPage(pageReqVO).getList(); + // 导出 Excel + ExcelUtils.write(response, "销售订单.xls", "数据", SaleOrderRespVO.class, + BeanUtils.toBean(list, SaleOrderRespVO.class)); + } + + // ==================== 子表(销售订单明细) ==================== + + @GetMapping("/sale-order-entry/list-by-parent-id") + @Operation(summary = "获得销售订单明细列表") + @Parameter(name = "parentId", description = "主表id") + public CommonResult> getSaleOrderEntryListByParentId(@RequestParam("parentId") Long parentId) { + return success(saleOrderService.getSaleOrderEntryListByParentId(parentId)); + } + + + @GetMapping("/get-count") + @Operation(summary = "获得销售订单 分页 tab count") + public CommonResult> getSpuCount() { + return success(saleOrderService.getTabsCount()); + } + + @PostMapping("/updateSaleOrderBillStatus") + @Operation(summary = "更新销售订单的状态") + public CommonResult updateSaleOrderBillStatus(@RequestParam("ids") List ids, + @RequestParam("status") String status) { + saleOrderService.updateSaleOrderBillStatus(ids, status); + return success(true); + } + + + @PutMapping("/generateProduceOrder") + @Operation(summary = "生成生成制单") + public CommonResult generateProduceOrder(@RequestParam("ids") List ids) { + saleOrderService.generateProduceOrder(ids); + return success(true); + } + + @PostMapping("/rejectOrder") + @Operation(summary = "驳回") + public CommonResult rejectOrder(@RequestBody JSONObject jobs) { + Long[] ids = jobs.getObject("ids", new Long[0].getClass()); + String reason = jobs.getString("reason"); + saleOrderService.updateSaleOrderBillStatus(Arrays.asList(ids), "reject", MapUtil.of("rejectReason",reason)); + return success(true); + } + + /** + * 生成PDF文档并下载 + * @param response HTTP响应 + * @throws Exception 异常 + */ + @GetMapping("/download") + @PermitAll + public void downloadPdf(HttpServletResponse response) throws Exception{ + saleOrderService.generatePdf(response); + } + @PutMapping("/update-remark") + @Operation(summary = "订单备注") + public CommonResult updateOrderRemark(@RequestBody SaleOrderRemarkReqVO reqVO) { + saleOrderService.updateOrderRemark(reqVO); + return success(true); + } + + + @PutMapping("/update-entrys") + @Operation(summary = "更新销售订单") + public CommonResult updateSaleOrderEntry(@Valid @RequestBody SaleOrderSaveReqVO updateReqVO) { + saleOrderService.updateSaleOrder(updateReqVO); + return success(true); + } + } \ No newline at end of file diff --git a/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/controller/admin/auth/AuthController.java b/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/controller/admin/auth/AuthController.java index 34da094..e3c4ea6 100644 --- a/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/controller/admin/auth/AuthController.java +++ b/hangtag-module-system/hangtag-module-system-biz/src/main/java/cn/hangtag/module/system/controller/admin/auth/AuthController.java @@ -103,6 +103,14 @@ public class AuthController { return success(null); } + if(user.getDeptId()==999999L){//当前账号等于客户类型 + AuthPermissionInfoRespVO convert = AuthConvert.INSTANCE.convert(user, null, null); + String customerMenuJson = ResourceUtil.readUtf8Str("json/customer_menu.json"); + List list = JSONUtil.toList(customerMenuJson, AuthPermissionInfoRespVO.MenuVO.class); + convert.setMenus(list); + return success(convert); + } + // 1.2 获得角色列表 Set roleIds = permissionService.getUserRoleIdListByUserId(getLoginUserId()); if (CollUtil.isEmpty(roleIds)) { @@ -117,11 +125,6 @@ public class AuthController { menuList.removeIf(menu -> !CommonStatusEnum.ENABLE.getStatus().equals(menu.getStatus())); // 移除禁用的菜单 AuthPermissionInfoRespVO convert = AuthConvert.INSTANCE.convert(user, roles, menuList); - if(user.getDeptId()==999999L){//当前账号等于客户类型 - String customerMenuJson = ResourceUtil.readUtf8Str("json/customer_menu.json"); - List list = JSONUtil.toList(customerMenuJson, AuthPermissionInfoRespVO.MenuVO.class); - convert.setMenus(list); - } // 2. 拼接结果返回 return success(convert); } diff --git a/hangtag-module-system/hangtag-module-system-biz/src/main/resources/json/customer_menu.json b/hangtag-module-system/hangtag-module-system-biz/src/main/resources/json/customer_menu.json index 1a87907..1276005 100644 --- a/hangtag-module-system/hangtag-module-system-biz/src/main/resources/json/customer_menu.json +++ b/hangtag-module-system/hangtag-module-system-biz/src/main/resources/json/customer_menu.json @@ -11,6 +11,19 @@ "keepAlive": true, "alwaysShow": true, "children": [ + { + "id": 2830, + "parentId": 2828, + "name": "销售订单", + "path": "sale-order", + "component": "oms/saleorder/index", + "componentName": "SaleOrder", + "icon": "", + "visible": true, + "keepAlive": true, + "alwaysShow": true, + "children": null + }, { "id": 2818, "parentId": 2804, diff --git a/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/oauth2/OAuth2OpenConvertImpl.java b/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/oauth2/OAuth2OpenConvertImpl.java index 047c0b8..57fcfa5 100644 --- a/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/oauth2/OAuth2OpenConvertImpl.java +++ b/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/oauth2/OAuth2OpenConvertImpl.java @@ -4,7 +4,7 @@ import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", - date = "2024-09-22T21:13:45+0800", + date = "2024-09-22T22:23:16+0800", comments = "version: 1.5.5.Final, compiler: javac, environment: Java 1.8.0_401 (Oracle Corporation)" ) public class OAuth2OpenConvertImpl implements OAuth2OpenConvert { diff --git a/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/social/SocialUserConvertImpl.java b/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/social/SocialUserConvertImpl.java index acbe755..5f91024 100644 --- a/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/social/SocialUserConvertImpl.java +++ b/hangtag-module-system/hangtag-module-system-biz/target/generated-sources/annotations/cn/hangtag/module/system/convert/social/SocialUserConvertImpl.java @@ -6,7 +6,7 @@ import javax.annotation.Generated; @Generated( value = "org.mapstruct.ap.MappingProcessor", - date = "2024-09-22T21:13:46+0800", + date = "2024-09-22T22:23:16+0800", comments = "version: 1.5.5.Final, compiler: javac, environment: Java 1.8.0_401 (Oracle Corporation)" ) public class SocialUserConvertImpl implements SocialUserConvert { diff --git a/hangtag-ui/hangtag-ui-front/src/api/oms/saleorder/index.ts b/hangtag-ui/hangtag-ui-front/src/api/oms/saleorder/index.ts new file mode 100644 index 0000000..24f275e --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/api/oms/saleorder/index.ts @@ -0,0 +1,108 @@ +import request from '@/config/axios' + +// 销售订单 VO +export interface SaleOrderVO { + id: number // 客户id + customerId: number // 客户id + bizdate: Date // 业务日期 + remark: string // 备注 + confirmdate: Date // 确认日期 + plansenddate: Date // 计划日期 + phone: string // 手机 + remarks: string // 备注 + emails: string // 邮件列表,数据格式:xxx@xx.com;xxx@xx.com; + invoiceCode: string // 发票抬头 + invoiceName: string // 发票名称 + address: string // 地址 + currency: string // 货币 + invoiceRemarks: string // 发票备注 +} + +// 销售订单 API +export const SaleOrderApi = { + // 查询销售订单分页 + getSaleOrderPage: async (params: any) => { + return await request.get({ url: `/oms/app/sale-order/page`, params }) + }, + + // 查询销售订单详情 + getSaleOrder: async (id: number) => { + return await request.get({ url: `/oms/app/sale-order/get?id=` + id }) + }, + + // 新增销售订单 + createSaleOrder: async (data: SaleOrderVO) => { + return await request.post({ url: `/oms/sale-order/create`, data }) + }, + + // 修改销售订单 + updateSaleOrder: async (data: SaleOrderVO) => { + return await request.put({ url: `/oms/app/sale-order/update`, data }) + }, + + // 删除销售订单 + deleteSaleOrder: async (id: number) => { + return await request.delete({ url: `/oms/app/sale-order/delete?id=` + id }) + }, + + // 导出销售订单 Excel + exportSaleOrder: async (params) => { + return await request.download({ url: `/oms/app/sale-order/export-excel`, params }) + }, + +// ==================== 子表(销售订单明细) ==================== + + // 获得销售订单明细列表 + getSaleOrderEntryListByParentId: async (parentId) => { + return await request.get({ url: `/oms/app/sale-order/sale-order-entry/list-by-parent-id?parentId=` + parentId }) + }, + + // 获得 销售 列表 tabsCount + getTabsCount: async () => { + return request.get({ url: '/oms/app/sale-order/get-count' }) + }, + + // 更新订单状态 + updateSaleOrderBillStatus: async (ids: number[],status: string) => { + return await request.post({ + url: `/oms/sale-order/updateSaleOrderBillStatus`, + params: { + ids: ids.join(','), + status: status + } + }) + }, + // 更新订单状态 + generateProduceOrder: async (ids: number[]) => { + return await request.put({ + url: `/oms/sale-order/generateProduceOrder`, + params: { + ids: ids.join(',') + } + }) + }, + + // 驳回 + rejectOrder: async (data) => { + return await request.post({ + url: `/oms/sale-order/rejectOrder`, + data: data + }) + }, + + // 修改订单地址 + updateOrderAddress: async (data: any) => { + return await request.put({ url: `/oms/sale-order/update-address`, data }) + }, + + // 订单备注 + updateOrderRemark: async (data: any) => { + return await request.put({ url: `/oms/sale-order/update-remark`, data }) + }, + + // 更新分录数据 + updateOrderEntrys: async (data: SaleOrderVO) => { + return await request.put({ url: `/oms/sale-order/update-entrys`, data }) + } + +} diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/order/createorder/index222.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/order/createorder/index222.vue new file mode 100644 index 0000000..004062f --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/order/createorder/index222.vue @@ -0,0 +1,392 @@ + + + + + + + diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/SaleOrderForm.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/SaleOrderForm.vue new file mode 100644 index 0000000..c1b29d5 --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/SaleOrderForm.vue @@ -0,0 +1,184 @@ + + \ No newline at end of file diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/components/SaleOrderEntryForm.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/components/SaleOrderEntryForm.vue new file mode 100644 index 0000000..4de7fb0 --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/components/SaleOrderEntryForm.vue @@ -0,0 +1,121 @@ + + \ No newline at end of file diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/components/SaleOrderEntryList.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/components/SaleOrderEntryList.vue new file mode 100644 index 0000000..7b47eec --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/components/SaleOrderEntryList.vue @@ -0,0 +1,123 @@ + + \ No newline at end of file diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/detail/index.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/detail/index.vue new file mode 100644 index 0000000..5b643d1 --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/detail/index.vue @@ -0,0 +1,357 @@ + + + diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/form/OrderUpdateAddressForm.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/form/OrderUpdateAddressForm.vue new file mode 100644 index 0000000..77cccdb --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/form/OrderUpdateAddressForm.vue @@ -0,0 +1,98 @@ + + diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/form/OrderUpdateRemarkForm.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/form/OrderUpdateRemarkForm.vue new file mode 100644 index 0000000..ca2ff94 --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/form/OrderUpdateRemarkForm.vue @@ -0,0 +1,70 @@ + + diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/index.vue b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/index.vue new file mode 100644 index 0000000..6946f2f --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/index.vue @@ -0,0 +1,589 @@ + + + + + + diff --git a/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/saleOrder.data.ts b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/saleOrder.data.ts new file mode 100644 index 0000000..5b1a89c --- /dev/null +++ b/hangtag-ui/hangtag-ui-front/src/views/oms/saleorder/saleOrder.data.ts @@ -0,0 +1,160 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter } from '@/utils/formatTime' + +// 表单校验 +export const rules = reactive({ +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive([ + { + label: '单据编号', + field: 'billno', + isSearch: true, + isForm: false, + }, + { + label: '客户id', + field: 'customerId', + isSearch: true, + form: { + component: 'InputNumber', + value: 0 + }, + }, + { + label: '业务日期', + field: 'bizdate', + formatter: dateFormatter, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'datetime', + valueFormat: 'x' + } + }, + }, + { + label: '备注', + field: 'remark', + form: { + component: 'Input', + componentProps: { + type: 'textarea', + rows: 4 + }, + colProps: { + span: 24 + } + }, + }, + { + label: '更新时间', + field: 'udate', + formatter: dateFormatter, + isForm: false, + }, + { + label: '创建时间', + field: 'cdate', + formatter: dateFormatter, + isForm: false, + }, + { + label: '确认日期', + field: 'confirmdate', + formatter: dateFormatter, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'datetime', + valueFormat: 'x' + } + }, + }, + { + label: '计划日期', + field: 'plansenddate', + formatter: dateFormatter, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'datetime', + valueFormat: 'x' + } + }, + }, + { + label: '手机', + field: 'phone', + isSearch: true, + }, + { + label: '备注', + field: 'remarks', + isSearch: true, + }, + { + label: '邮件列表,数据格式:xxx@xx.com;xxx@xx.com;', + field: 'emails', + isTable: false, + }, + { + label: '发票抬头', + field: 'invoiceCode', + isTable: false, + }, + { + label: '发票名称', + field: 'invoiceName', + isTable: false, + }, + { + label: '地址', + field: 'address', + isTable: false, + }, + { + label: '货币', + field: 'currency', + isTable: false, + }, + { + label: '发票备注', + field: 'invoiceRemarks', + isTable: false, + }, + { + label: '操作', + field: 'action', + isForm: false + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) \ No newline at end of file